elements constructor улучшен
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { Save } from 'lucide-react'
|
||||||
import { memo } from 'react'
|
import { memo } from 'react'
|
||||||
import { FormulaBarProps } from '../types'
|
import { FormulaBarProps } from '../types'
|
||||||
import { getColumnLabel } from '../utils'
|
import { getColumnLabel } from '../utils'
|
||||||
@@ -163,19 +164,7 @@ export const FormulaBar = memo(
|
|||||||
: 'Все изменения сохранены'
|
: 'Все изменения сохранены'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<svg
|
<Save className="h-3 w-3" />
|
||||||
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
|
{lastSaveError
|
||||||
? 'Повторить'
|
? 'Повторить'
|
||||||
: hasUnsavedChanges
|
: hasUnsavedChanges
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
|
||||||
} from '../ui/dialog'
|
} from '../ui/dialog'
|
||||||
import { Input } from '../ui/input'
|
import { Input } from '../ui/input'
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +44,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '../ui/select'
|
} from '../ui/select'
|
||||||
import { Textarea } from '../ui/textarea'
|
import { Textarea } from '../ui/textarea'
|
||||||
|
import { ElementFormulaBar } from './ElementFormulaBar'
|
||||||
import { VisualLayoutEditor } from './VisualLayoutEditor'
|
import { VisualLayoutEditor } from './VisualLayoutEditor'
|
||||||
|
|
||||||
interface ElementFormProps {
|
interface ElementFormProps {
|
||||||
@@ -705,6 +705,14 @@ interface ElementConstructorProps {
|
|||||||
) => void
|
) => void
|
||||||
onElementDelete: (elementId: string) => void
|
onElementDelete: (elementId: string) => void
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
// Пропсы для сохранения
|
||||||
|
hasUnsavedChanges?: boolean
|
||||||
|
isSaving?: boolean
|
||||||
|
lastSaveError?: string | null
|
||||||
|
onManualSave?: () => Promise<void>
|
||||||
|
onClearError?: () => void
|
||||||
|
// Пропсы для настроек макета
|
||||||
|
onLayoutSettingsChange?: (settings: FormLayoutSettings) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||||
@@ -713,6 +721,12 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
onElementUpdate,
|
onElementUpdate,
|
||||||
onElementDelete,
|
onElementDelete,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
isSaving = false,
|
||||||
|
lastSaveError = null,
|
||||||
|
onManualSave,
|
||||||
|
onClearError,
|
||||||
|
onLayoutSettingsChange,
|
||||||
}) => {
|
}) => {
|
||||||
const elements = template.elements
|
const elements = template.elements
|
||||||
const layoutSettings = template.layoutSettings || {
|
const layoutSettings = template.layoutSettings || {
|
||||||
@@ -877,24 +891,44 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full">
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
{/* Main Content */}
|
<ElementFormulaBar
|
||||||
<div className="flex flex-1 flex-col bg-gray-50">
|
elementCount={elements.length}
|
||||||
<div className="flex items-center justify-between border-b bg-white p-2">
|
onAddElement={() => setIsAddDialogOpen(true)}
|
||||||
<div className="px-2">
|
onAutoArrange={handleAutoArrange}
|
||||||
<h3 className="text-sm font-medium">Холст</h3>
|
disabled={disabled}
|
||||||
<p className="text-xs text-gray-500">
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
Перетаскивайте элементы для настройки их расположения
|
isSaving={isSaving}
|
||||||
</p>
|
lastSaveError={lastSaveError}
|
||||||
|
onManualSave={onManualSave}
|
||||||
|
onClearError={onClearError}
|
||||||
|
showGrid={layoutSettings.showGrid}
|
||||||
|
onToggleGrid={
|
||||||
|
onLayoutSettingsChange
|
||||||
|
? () => {
|
||||||
|
const newSettings = {
|
||||||
|
...layoutSettings,
|
||||||
|
showGrid: !layoutSettings.showGrid,
|
||||||
|
}
|
||||||
|
onLayoutSettingsChange(newSettings)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<VisualLayoutEditor
|
||||||
|
elements={elements}
|
||||||
|
layoutSettings={layoutSettings}
|
||||||
|
onElementUpdate={onElementUpdate}
|
||||||
|
onElementDelete={handleDeleteAndSelect}
|
||||||
|
onLayoutSettingsChange={onLayoutSettingsChange || (() => {})}
|
||||||
|
onElementEdit={handleEditElement}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
{/* Диалог добавления элемента */}
|
||||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
<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">
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Добавить новый элемент</DialogTitle>
|
<DialogTitle>Добавить новый элемент</DialogTitle>
|
||||||
@@ -904,10 +938,7 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<ElementForm formData={formData} setFormData={setFormData} />
|
<ElementForm formData={formData} setFormData={setFormData} />
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button
|
<Button onClick={() => setIsAddDialogOpen(false)} variant="outline">
|
||||||
onClick={() => setIsAddDialogOpen(false)}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -928,11 +959,9 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Диалог редактирования элемента */}
|
||||||
{editingElement && (
|
{editingElement && (
|
||||||
<Dialog
|
<Dialog open={!!editingElement} onOpenChange={() => handleCancelEdit()}>
|
||||||
open={!!editingElement}
|
|
||||||
onOpenChange={() => handleCancelEdit()}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Редактировать элемент</DialogTitle>
|
<DialogTitle>Редактировать элемент</DialogTitle>
|
||||||
@@ -953,18 +982,5 @@ export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<VisualLayoutEditor
|
|
||||||
elements={elements}
|
|
||||||
layoutSettings={layoutSettings}
|
|
||||||
onElementUpdate={onElementUpdate}
|
|
||||||
onElementDelete={handleDeleteAndSelect}
|
|
||||||
onLayoutSettingsChange={() => {}} // Настройки макета только для чтения
|
|
||||||
onElementEdit={handleEditElement}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
190
src/component/TemplateManager/ElementFormulaBar.tsx
Normal file
190
src/component/TemplateManager/ElementFormulaBar.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Eye, EyeOff, Plus, Save, Wrench } from 'lucide-react'
|
||||||
|
import { memo } from 'react'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
|
interface ElementFormulaBarProps {
|
||||||
|
elementCount: number
|
||||||
|
onAddElement: () => void
|
||||||
|
onAutoArrange?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
// Пропсы для кнопки сохранения
|
||||||
|
hasUnsavedChanges?: boolean
|
||||||
|
isSaving?: boolean
|
||||||
|
lastSaveError?: string | null
|
||||||
|
onManualSave?: () => Promise<void>
|
||||||
|
onClearError?: () => void
|
||||||
|
// Пропсы для управления сеткой
|
||||||
|
showGrid?: boolean
|
||||||
|
onToggleGrid?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ElementFormulaBar = memo(
|
||||||
|
({
|
||||||
|
elementCount,
|
||||||
|
onAddElement,
|
||||||
|
onAutoArrange,
|
||||||
|
disabled = false,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
isSaving = false,
|
||||||
|
lastSaveError = null,
|
||||||
|
onManualSave,
|
||||||
|
onClearError,
|
||||||
|
showGrid = true,
|
||||||
|
onToggleGrid,
|
||||||
|
}: ElementFormulaBarProps) => {
|
||||||
|
return (
|
||||||
|
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex min-w-[100px] items-center justify-center rounded-md border bg-muted px-2 py-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{elementCount} элементов
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAddElement}
|
||||||
|
disabled={disabled}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
{onAutoArrange && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onAutoArrange}
|
||||||
|
disabled={disabled || elementCount === 0}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Wrench className="mr-1 h-3 w-3" />
|
||||||
|
Упорядочить
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onToggleGrid && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleGrid}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{showGrid ? (
|
||||||
|
<EyeOff className="mr-1 h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Eye className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{showGrid ? 'Скрыть сетку' : 'Показать'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Ошибка сохранения */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор сохранения */}
|
||||||
|
{isSaving && (
|
||||||
|
<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">Сохранение...</span>
|
||||||
|
</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
|
||||||
|
? 'Есть несохраненные изменения'
|
||||||
|
: 'Все изменения сохранены'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save className="h-3 w-3" />
|
||||||
|
{lastSaveError
|
||||||
|
? 'Повторить'
|
||||||
|
: hasUnsavedChanges
|
||||||
|
? 'Сохранить'
|
||||||
|
: 'Сохранено'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
128
src/component/TemplateManager/SaveButton.tsx
Normal file
128
src/component/TemplateManager/SaveButton.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
|
||||||
|
interface SaveButtonProps {
|
||||||
|
hasUnsavedChanges: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
lastSaveError: string | null
|
||||||
|
onManualSave: () => Promise<void>
|
||||||
|
onClearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveButton = memo(
|
||||||
|
({
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
lastSaveError,
|
||||||
|
onManualSave,
|
||||||
|
onClearError,
|
||||||
|
}: SaveButtonProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Ошибка сохранения */}
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Индикатор сохранения */}
|
||||||
|
{isSaving && (
|
||||||
|
<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">Сохранение...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Кнопка сохранения */}
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
Droplets,
|
Droplets,
|
||||||
Edit,
|
Edit,
|
||||||
Edit3,
|
Edit3,
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Gauge,
|
Gauge,
|
||||||
Grid,
|
Grid,
|
||||||
Move,
|
Move,
|
||||||
@@ -460,21 +458,7 @@ export const VisualLayoutEditor: React.FC<VisualLayoutEditorProps> = ({
|
|||||||
}, [layoutSettings, onLayoutSettingsChange])
|
}, [layoutSettings, onLayoutSettingsChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-gray-50">
|
<div className="flex h-full flex-col bg-background">
|
||||||
<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
|
<div
|
||||||
className="relative flex-1 overflow-auto"
|
className="relative flex-1 overflow-auto"
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
|
|||||||
111
src/hook/useDelayedSave.ts
Normal file
111
src/hook/useDelayedSave.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface UseDelayedSaveOptions<T> {
|
||||||
|
onSave: (data: T) => Promise<void>
|
||||||
|
onSuccess?: (data: T) => void // Коллбек после успешного сохранения
|
||||||
|
delay?: number // Задержка в миллисекундах (по умолчанию 2000мс)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseDelayedSaveReturn<T> {
|
||||||
|
hasUnsavedChanges: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
lastSaveError: string | null
|
||||||
|
updateData: (data: T) => void
|
||||||
|
manualSave: () => Promise<void>
|
||||||
|
clearError: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDelayedSave<T>({
|
||||||
|
onSave,
|
||||||
|
onSuccess,
|
||||||
|
delay = 2000,
|
||||||
|
}: UseDelayedSaveOptions<T>): UseDelayedSaveReturn<T> {
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [lastSaveError, setLastSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const pendingDataRef = useRef<T | null>(null)
|
||||||
|
|
||||||
|
// Очистка таймера при размонтировании компонента
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setLastSaveError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const performSave = useCallback(
|
||||||
|
async (data: T) => {
|
||||||
|
if (isSaving) return
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
setLastSaveError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onSave(data)
|
||||||
|
setHasUnsavedChanges(false)
|
||||||
|
pendingDataRef.current = null
|
||||||
|
// Вызываем коллбек успешного сохранения
|
||||||
|
onSuccess?.(data)
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Неизвестная ошибка'
|
||||||
|
setLastSaveError(errorMessage)
|
||||||
|
console.error('Ошибка при сохранении:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSave, onSuccess, isSaving]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateData = useCallback(
|
||||||
|
(data: T) => {
|
||||||
|
// Отменяем предыдущий таймер
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем данные для отложенного сохранения
|
||||||
|
pendingDataRef.current = data
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
|
setLastSaveError(null)
|
||||||
|
|
||||||
|
// Устанавливаем новый таймер
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
if (pendingDataRef.current) {
|
||||||
|
performSave(pendingDataRef.current)
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
[delay, performSave]
|
||||||
|
)
|
||||||
|
|
||||||
|
const manualSave = useCallback(async () => {
|
||||||
|
// Отменяем автоматическое сохранение
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем немедленно, если есть несохранённые данные
|
||||||
|
if (pendingDataRef.current) {
|
||||||
|
await performSave(pendingDataRef.current)
|
||||||
|
}
|
||||||
|
}, [performSave])
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
lastSaveError,
|
||||||
|
updateData,
|
||||||
|
manualSave,
|
||||||
|
clearError,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ElementConstructor } from '@/component/TemplateManager/ElementConstructor'
|
import { ElementConstructor } from '@/component/TemplateManager/ElementConstructor'
|
||||||
import { Button } from '@/component/ui/button'
|
import { Button } from '@/component/ui/button'
|
||||||
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
import { useTemplateContext } from '@/entitiy/template/model/TemplateContext'
|
||||||
|
import { useDelayedSave } from '@/hook/useDelayedSave'
|
||||||
import { Template, TemplateElement } from '@/type/template'
|
import { Template, TemplateElement } from '@/type/template'
|
||||||
import { ArrowLeft, FileText, Wrench } from 'lucide-react'
|
import { ArrowLeft, FileText, Wrench } from 'lucide-react'
|
||||||
import { FC, useEffect, useState } from 'react'
|
import { FC, useCallback, useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
export const ElementsCreation: FC = () => {
|
export const ElementsCreation: FC = () => {
|
||||||
@@ -13,48 +14,56 @@ export const ElementsCreation: FC = () => {
|
|||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isInitialized, setIsInitialized] = useState(false)
|
||||||
|
|
||||||
// Автоматически выбираем шаблон, если передан templateId
|
// Автоматически выбираем шаблон, если передан templateId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (templateId) {
|
if (templateId && !isInitialized) {
|
||||||
const template = templates.find(t => t.id === templateId)
|
const template = templates.find(t => t.id === templateId)
|
||||||
if (template) {
|
if (template) {
|
||||||
setSelectedTemplate(template)
|
setSelectedTemplate(template)
|
||||||
|
setIsInitialized(true)
|
||||||
} else if (!isLoading && templates.length > 0) {
|
} else if (!isLoading && templates.length > 0) {
|
||||||
// Если шаблон не найден после загрузки, перенаправляем на страницу со списком шаблонов
|
// Если шаблон не найден после загрузки, перенаправляем на страницу со списком шаблонов
|
||||||
navigate('/templates', { replace: true })
|
navigate('/templates', { replace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [templateId, templates, navigate, isLoading])
|
}, [templateId, templates, navigate, isLoading, isInitialized])
|
||||||
|
|
||||||
|
// Функция для сохранения шаблона в БД
|
||||||
|
const saveTemplate = useCallback(
|
||||||
|
async (template: Template) => {
|
||||||
|
await updateTemplate(template)
|
||||||
|
},
|
||||||
|
[updateTemplate]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Хук для отложенного сохранения
|
||||||
|
const {
|
||||||
|
hasUnsavedChanges,
|
||||||
|
isSaving,
|
||||||
|
lastSaveError,
|
||||||
|
updateData,
|
||||||
|
manualSave,
|
||||||
|
clearError,
|
||||||
|
} = useDelayedSave({
|
||||||
|
onSave: saveTemplate,
|
||||||
|
delay: 2000, // 2 секунды задержки
|
||||||
|
})
|
||||||
|
|
||||||
const handleElementAdd = async (element: TemplateElement) => {
|
const handleElementAdd = async (element: TemplateElement) => {
|
||||||
// console.log('handleElementAdd called with element:', element)
|
|
||||||
|
|
||||||
if (!selectedTemplate) {
|
if (!selectedTemplate) {
|
||||||
// console.log('No selected template')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate,
|
...selectedTemplate,
|
||||||
elements: [...selectedTemplate.elements, element],
|
elements: [...selectedTemplate.elements, element],
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('Updated template:', updatedTemplate)
|
|
||||||
// console.log('Elements count:', updatedTemplate.elements.length)
|
|
||||||
|
|
||||||
await updateTemplate(updatedTemplate)
|
|
||||||
setSelectedTemplate(updatedTemplate)
|
setSelectedTemplate(updatedTemplate)
|
||||||
} catch (error) {
|
updateData(updatedTemplate)
|
||||||
console.error('Ошибка при добавлении элемента:', error)
|
|
||||||
alert('Ошибка при добавлении элемента. Попробуйте еще раз.')
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleElementUpdate = async (
|
const handleElementUpdate = async (
|
||||||
@@ -63,8 +72,6 @@ export const ElementsCreation: FC = () => {
|
|||||||
) => {
|
) => {
|
||||||
if (!selectedTemplate) return
|
if (!selectedTemplate) return
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate,
|
...selectedTemplate,
|
||||||
elements: selectedTemplate.elements.map(el =>
|
elements: selectedTemplate.elements.map(el =>
|
||||||
@@ -81,60 +88,70 @@ export const ElementsCreation: FC = () => {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateTemplate(updatedTemplate)
|
|
||||||
setSelectedTemplate(updatedTemplate)
|
setSelectedTemplate(updatedTemplate)
|
||||||
} catch (error) {
|
updateData(updatedTemplate)
|
||||||
console.error('Ошибка при обновлении элемента:', error)
|
|
||||||
alert('Ошибка при обновлении элемента. Попробуйте еще раз.')
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleElementDelete = async (elementId: string) => {
|
const handleElementDelete = async (elementId: string) => {
|
||||||
if (!selectedTemplate) return
|
if (!selectedTemplate) return
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const updatedTemplate = {
|
const updatedTemplate = {
|
||||||
...selectedTemplate,
|
...selectedTemplate,
|
||||||
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
|
elements: selectedTemplate.elements.filter(el => el.id !== elementId),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateTemplate(updatedTemplate)
|
|
||||||
setSelectedTemplate(updatedTemplate)
|
setSelectedTemplate(updatedTemplate)
|
||||||
} catch (error) {
|
updateData(updatedTemplate)
|
||||||
console.error('Ошибка при удалении элемента:', error)
|
|
||||||
alert('Ошибка при удалении элемента. Попробуйте еще раз.')
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Компактные уведомления вместо полноэкранных блокеров
|
// if (isLoading) {
|
||||||
if (isLoading || error || !selectedTemplate) {
|
// return (
|
||||||
|
// <div className="flex h-screen 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-primary border-t-transparent"></div>
|
||||||
|
// <p className="text-sm text-muted-foreground">Загрузка шаблонов...</p>
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<div
|
<div className="text-center">
|
||||||
className={`rounded-md border px-4 py-2 text-sm ${
|
<h3 className="mb-2 text-lg font-medium text-destructive">
|
||||||
error
|
Ошибка загрузки
|
||||||
? 'border-destructive/50 bg-destructive/10 text-destructive'
|
</h3>
|
||||||
: !selectedTemplate
|
<p className="mb-4 text-sm text-muted-foreground">{error.message}</p>
|
||||||
? 'border-destructive/50 bg-destructive/10 text-destructive'
|
<Button onClick={() => window.location.reload()}>
|
||||||
: 'border-muted bg-muted/20 text-muted-foreground'
|
Обновить страницу
|
||||||
}`}
|
</Button>
|
||||||
>
|
</div>
|
||||||
{isLoading && 'Загрузка шаблона...'}
|
</div>
|
||||||
{error && `Ошибка загрузки: ${error}`}
|
)
|
||||||
{!selectedTemplate && 'Шаблон не найден'}
|
}
|
||||||
|
|
||||||
|
if (!selectedTemplate) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Wrench className="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
|
||||||
|
<h3 className="mb-2 text-lg font-medium">Шаблон не найден</h3>
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
Перейдите к списку шаблонов для выбора
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/templates')}>
|
||||||
|
К списку шаблонов
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0 border-b bg-card px-4 py-1">
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="flex-shrink-0 border-b bg-card px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -178,22 +195,22 @@ export const ElementsCreation: FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Индикатор сохранения */}
|
|
||||||
{isSaving && (
|
|
||||||
<div className="ml-auto flex items-center space-x-2 text-sm text-muted-foreground">
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent"></div>
|
|
||||||
<span>Сохранение...</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
<ElementConstructor
|
<ElementConstructor
|
||||||
template={selectedTemplate}
|
template={selectedTemplate}
|
||||||
onElementAdd={handleElementAdd}
|
onElementAdd={handleElementAdd}
|
||||||
onElementUpdate={handleElementUpdate}
|
onElementUpdate={handleElementUpdate}
|
||||||
onElementDelete={handleElementDelete}
|
onElementDelete={handleElementDelete}
|
||||||
disabled={isSaving}
|
disabled={false}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
isSaving={isSaving}
|
||||||
|
lastSaveError={lastSaveError}
|
||||||
|
onManualSave={manualSave}
|
||||||
|
onClearError={clearError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user