Новые стили, починил фокус formulabar

This commit is contained in:
2025-07-24 13:01:59 +03:00
parent 66b94cdd8f
commit 80e97f8334
17 changed files with 555 additions and 408 deletions

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -46,6 +46,7 @@
"react-window": "^1.8.11",
"redux": "^4.2.1",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"use-debounce": "^10.0.5"
},
"devDependencies": {

View File

@@ -10,20 +10,25 @@
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
@@ -34,7 +39,7 @@
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 84% 4.9%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
@@ -46,6 +51,11 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}

View File

@@ -3,6 +3,7 @@ import { Input } from '@/component/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
@@ -104,30 +105,34 @@ export const selectDefinition: ElementDefinition<SelectConfig, string> = {
)
},
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>
<Select>
<SelectTrigger className="w-full min-w-32">
<SelectValue placeholder={config.placeholder || 'Выберите значение'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options.map((option, index) => (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
),
Render: ({ config, value, onChange }) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-full min-w-32">
<SelectValue placeholder={config.placeholder || 'Выберите значение'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options.map((option, index) => (
<SelectItem key={index} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
),

View File

@@ -41,6 +41,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
templateId,
onEngineReady,
enableAutoSave = true,
excelFileName,
isExcelLoading = false,
onExcelFileChange,
}) => {
// Мемоизируем конфигурацию листов
const sheetsConfig = useMemo(
@@ -345,11 +348,7 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
}, [isEditing])
// Синхронизация между formula bar и ячейкой при редактировании
useEffect(() => {
if (isEditing && formulaBarRef.current) {
formulaBarRef.current.value = editingValue
}
}, [editingValue, isEditing])
// Теперь полагаемся на controlled компонент через formulaBarValue
// Обновление подсветки ячеек при вводе формулы
useEffect(() => {
@@ -774,7 +773,10 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
// Обычный клик - очищаем множественное выделение
setMultiSelection([])
setSelectedCell({ row, col })
// Не возвращаем фокус на контейнер, если он сейчас на formula bar
if (document.activeElement !== formulaBarRef.current) {
containerRefs.current[activeSheet]?.focus({ preventScroll: true })
}
},
[isEditing, stopEditing, activeSheet, selectedCell, copiedCells]
)
@@ -919,8 +921,30 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
const handleFormulaBarFocus = useCallback(() => {
if (selectedCell && !isEditing) {
// Получаем позицию клика, если она была сохранена
const clickPosition = formulaBarRef.current?.dataset.clickPosition
const cursorPosition = clickPosition
? parseInt(clickPosition)
: formulaBarRef.current?.selectionStart || 0
setEditingSource('formula')
startEditing()
// Восстанавливаем позицию курсора после того как React обновит value
setTimeout(() => {
if (formulaBarRef.current) {
if (document.activeElement !== formulaBarRef.current) {
formulaBarRef.current.focus()
}
// Восстанавливаем вычисленную позицию курсора
formulaBarRef.current.setSelectionRange(
cursorPosition,
cursorPosition
)
// Очищаем сохраненную позицию
delete formulaBarRef.current.dataset.clickPosition
}
}, 0)
}
}, [selectedCell, isEditing, startEditing])
@@ -1155,6 +1179,9 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({
lastSaveError={lastSaveError}
onManualSave={manualSave}
onClearError={clearError}
excelFileName={excelFileName}
isExcelLoading={isExcelLoading}
onExcelFileChange={onExcelFileChange}
/>
<div
className="flex min-w-0 flex-1 gap-1 overflow-hidden"

View File

@@ -1,5 +1,6 @@
import { Redo2, Save, Undo2 } from 'lucide-react'
import { Save } from 'lucide-react'
import { memo } from 'react'
import { ExcelUploadPanel } from '../../TemplateManager/ExcelUploadPanel'
import { FormulaBarProps } from '../types'
import { getColumnLabel } from '../utils'
@@ -20,17 +21,23 @@ export const FormulaBar = memo(
lastSaveError = null,
onManualSave,
onClearError,
excelFileName,
isExcelLoading = false,
onExcelFileChange,
}: 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">
<div className="formula-bar flex-shrink-0 border-b bg-background px-3 py-1.5">
<div className="flex items-center space-x-3">
{/* Адрес ячейки */}
<div className="flex min-w-[45px] items-center justify-center rounded border bg-muted px-1.5 py-0.5">
<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
@@ -43,37 +50,81 @@ export const FormulaBar = memo(
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"
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 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' : ''}`}
className={`text-m flex-1 rounded border border-input bg-background px-2 py-0.5 outline-none ring-offset-background transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 ${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 // Не завершаем редактирование
return
}
onBlur(e)
}}
onKeyDown={onKeyDown}
onMouseDown={e => {
// Если поле не в режиме редактирования, нужно запустить редактирование
// но сохранить позицию курсора
if (!isEditing) {
// Вычисляем позицию клика вручную
const input = e.currentTarget
const rect = input.getBoundingClientRect()
const clickX =
e.clientX -
rect.left -
parseInt(getComputedStyle(input).paddingLeft)
// Создаем временный range для определения позиции
const tempSpan = document.createElement('span')
tempSpan.textContent = input.value
tempSpan.style.cssText = getComputedStyle(input).cssText
tempSpan.style.position = 'absolute'
tempSpan.style.visibility = 'hidden'
tempSpan.style.whiteSpace = 'pre'
document.body.appendChild(tempSpan)
let position = 0
let bestDistance = Infinity
for (let i = 0; i <= input.value.length; i++) {
tempSpan.textContent = input.value.substring(0, i)
const distance = Math.abs(tempSpan.offsetWidth - clickX)
if (distance < bestDistance) {
bestDistance = distance
position = i
}
}
document.body.removeChild(tempSpan)
// Сохраняем вычисленную позицию в data-атрибуте
input.dataset.clickPosition = position.toString()
// Запускаем редактирование
onFocus()
}
}}
/>
</div>
{/* Правая группа: индикаторы и действия */}
<div className="flex items-center gap-2">
{/* Индикатор процесса */}
{(isCalculating || isSaving || isLoading) && (
<div className="flex items-center text-muted-foreground">
<svg
className="h-3.5 w-3.5 animate-spin"
className="h-2.5 w-2.5 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
@@ -100,6 +151,7 @@ export const FormulaBar = memo(
</span>
</div>
)}
{/* Ошибка сохранения */}
{lastSaveError && (
<div className="flex items-center gap-1 rounded-md bg-destructive/10 px-2 py-1">
@@ -120,15 +172,15 @@ export const FormulaBar = memo(
className="text-xs text-destructive"
title={lastSaveError}
>
Ошибка сохранения
Ошибка
</span>
{onClearError && (
<button
onClick={onClearError}
className="ml-1 text-destructive/60 hover:text-destructive"
className="ml-0.5 text-destructive/60 hover:text-destructive"
>
<svg
className="h-3 w-3"
className="h-2 w-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -144,31 +196,13 @@ export const FormulaBar = memo(
)}
</div>
)}
{/* Кнопки отмены и повтора */}
{
<div className="flex items-center gap-1">
<button
disabled={true}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
title="Отменить (Ctrl+Z)"
>
<Undo2 className="h-3 w-3" />
</button>
<button
disabled={true}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/80 disabled:cursor-not-allowed disabled:opacity-50"
title="Повторить (Ctrl+Y)"
>
<Redo2 className="h-3 w-3" />
</button>
</div>
}
{/* Кнопка ручного сохранения */}
{/* Кнопка сохранения */}
{onManualSave && (
<button
onClick={onManualSave}
disabled={isSaving}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-xs transition-colors ${
hasUnsavedChanges
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: lastSaveError
@@ -184,13 +218,25 @@ export const FormulaBar = memo(
}
>
<Save className="h-3 w-3" />
<span className="hidden sm:inline">
{lastSaveError
? 'Повторить'
: hasUnsavedChanges
? 'Сохранить'
: 'Сохранено'}
</span>
</button>
)}
{/* ExcelUploadPanel */}
{onExcelFileChange && (
<ExcelUploadPanel
fileName={excelFileName}
isLoading={isExcelLoading}
onUploadClick={() => {}}
onFileChange={onExcelFileChange}
/>
)}
</div>
</div>
</div>

View File

@@ -53,6 +53,10 @@ export interface DualSpreadsheetProps {
templateId?: string // Добавляем ID шаблона для сохранения
onEngineReady?: (engine: any) => void // Коллбэк, который отдаёт родителю методы движка
enableAutoSave?: boolean // Возможность отключить автосохранение
// Пропсы для ExcelUploadPanel
excelFileName?: string
isExcelLoading?: boolean
onExcelFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export interface CellProps {
@@ -122,6 +126,10 @@ export interface FormulaBarProps {
lastSaveError?: string | null
onManualSave?: () => void
onClearError?: () => void
// Пропсы для ExcelUploadPanel
excelFileName?: string
isExcelLoading?: boolean
onExcelFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export interface CellRendererData {

View File

@@ -1,4 +1,5 @@
import { Button } from '@/component/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/component/ui/popover'
import { FileText, Upload } from 'lucide-react'
import React, { useRef } from 'react'
@@ -18,7 +19,53 @@ export const ExcelUploadPanel: React.FC<ExcelUploadPanelProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null)
return (
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 gap-1 px-2 text-xs"
disabled={isLoading}
>
<FileText
className={`h-3 w-3 ${fileName ? 'text-emerald-600' : 'text-gray-400'}`}
/>
{isLoading ? (
<div className="h-2 w-2 animate-spin rounded-full border border-current border-t-transparent" />
) : (
<span className={fileName ? 'text-emerald-700' : 'text-gray-500'}>
Шаблон
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="end">
<div className="space-y-3">
<div className="space-y-1">
<h4 className="text-sm font-medium">Excel файл</h4>
<p className="text-xs text-muted-foreground">
Загрузите или обновите Excel файл для шаблона
</p>
</div>
{fileName && (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
Текущий файл:
</label>
<div className="flex items-center gap-2 rounded border bg-emerald-50 p-2">
<FileText className="h-3 w-3 text-emerald-600" />
<span
className="flex-1 text-sm text-emerald-700"
title={fileName}
>
{fileName}
</span>
</div>
</div>
)}
<div className="space-y-2">
<input
ref={fileInputRef}
type="file"
@@ -26,39 +73,28 @@ export const ExcelUploadPanel: React.FC<ExcelUploadPanelProps> = ({
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={`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}
className="w-full gap-2"
size="sm"
>
{isLoading ? (
<>
<div className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
Загрузка...
</>
) : (
<>
<Upload className="h-3 w-3" />
{fileName ? 'Заменить файл' : 'Загрузить файл'}
</>
)}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -18,6 +18,7 @@ export const FormattingToolbar: React.FC = () => (
<div className="flex items-center">
{[Bold, Italic, Underline].map((Icon, i) => (
<Button
disabled
key={i}
variant="ghost"
size="sm"
@@ -33,6 +34,7 @@ export const FormattingToolbar: React.FC = () => (
<div className="flex items-center">
{[AlignLeft, AlignCenter, AlignRight].map((Icon, i) => (
<Button
disabled
key={i}
variant="ghost"
size="sm"
@@ -48,6 +50,7 @@ export const FormattingToolbar: React.FC = () => (
<div className="flex items-center">
{[Palette, Type, Grid].map((Icon, i) => (
<Button
disabled
key={i}
variant="ghost"
size="sm"

View File

@@ -7,6 +7,10 @@ interface SpreadsheetViewerProps {
mergedCells: ExcelParseResult['mergedCells']
dataVersion: number
templateId: string | undefined
// Пропсы для ExcelUploadPanel
excelFileName?: string
isExcelLoading?: boolean
onExcelFileChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}
export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
@@ -14,6 +18,9 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
mergedCells,
dataVersion,
templateId,
excelFileName,
isExcelLoading,
onExcelFileChange,
}) => (
<div className="min-h-0 flex-1">
<DualSpreadsheet
@@ -21,6 +28,9 @@ export const SpreadsheetViewer: React.FC<SpreadsheetViewerProps> = ({
templateData={{ L: data }}
mergedCells={mergedCells}
templateId={templateId}
excelFileName={excelFileName}
isExcelLoading={isExcelLoading}
onExcelFileChange={onExcelFileChange}
/>
</div>
)

View File

@@ -6,8 +6,6 @@ import {
} from '@/service/fileApiService'
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'
@@ -113,22 +111,14 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
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}
excelFileName={serverFileName || editedTemplate.excelFile?.name}
isExcelLoading={isLoading}
onExcelFileChange={handleFileChange}
/>
</div>
)

View File

@@ -1,45 +1,51 @@
import { cn } from '@/lib/utils'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?:
| 'default'
| 'destructive'
| 'outline'
| 'secondary'
| 'ghost'
| 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
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
)}
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
@@ -48,4 +54,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
)
Button.displayName = 'Button'
export { Button }
export { Button, buttonVariants }

View File

@@ -1,78 +1,67 @@
'use client'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '../../lib/utils'
const Select = SelectPrimitive.Root
import { cn } from '@/lib/utils'
const SelectGroup = SelectPrimitive.Group
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
const SelectValue = SelectPrimitive.Value
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default'
}) {
return (
<SelectPrimitive.Trigger
ref={ref}
data-slot="select-trigger"
data-size={size}
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',
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 data-[placeholder]:text-muted-foreground *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
<ChevronDownIcon className="size-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) => (
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
data-slot="select-content"
className={cn(
'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 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 max-h-(--radix-select-content-available-height) origin-(--radix-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
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
@@ -85,7 +74,7 @@ const SelectContent = React.forwardRef<
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
)}
>
{children}
@@ -93,55 +82,94 @@ const SelectContent = React.forwardRef<
<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) => (
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
data-slot="select-label"
className={cn('px-2 py-1.5 text-xs text-muted-foreground', 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) => (
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
ref={ref}
data-slot="select-item"
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',
"outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<CheckIcon className="size-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) => (
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
data-slot="select-separator"
className={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,

View File

@@ -1,57 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -280,8 +280,8 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
const canSave = true
return (
<div className="flex h-screen flex-col bg-gray-50">
<div className="flex-shrink-0 border-b bg-card px-4 py-2">
<div className="flex h-screen flex-col bg-background">
<div className="flex-shrink-0 bg-card px-4 py-2">
<div className="relative flex items-center">
{/* Левая часть: кнопка назад и заголовок */}
<div className="flex items-center gap-2">
@@ -297,6 +297,23 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
</span>
</div>
</div>
{/* Центрированные кнопки */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
>
<Grid className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
<Save className="h-4 w-4" />
Сохранить протокол
</Button>
</div>
{/* Кнопки справа */}
<div className="absolute right-0 flex gap-2">
<Button
@@ -318,23 +335,6 @@ const ProtocolForm: FC<ProtocolFormProps> = ({ template, onSave, onBack }) => {
<span className="text-sm">Редактор интерфейса</span>
</Button>
</div>
{/* Центрированные кнопки */}
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-2">
<Button
variant={showSpreadsheet ? 'default' : 'outline'}
size="icon"
onClick={() => setShowSpreadsheet(!showSpreadsheet)}
aria-label={
showSpreadsheet ? 'Скрыть таблицы' : 'Показать таблицы'
}
>
<Grid className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleSave} disabled={!canSave}>
<Save className="h-4 w-4" />
Сохранить протокол
</Button>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
@@ -11,39 +12,46 @@ export default {
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
foreground: 'hsl(var(--accent-foreground))'
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
foreground: 'hsl(var(--popover-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
foreground: 'hsl(var(--card-foreground))'
},
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
sm: 'calc(var(--radius) - 4px)'
}
}
},
},
},
plugins: [],
plugins: [require("tailwindcss-animate")],
}

View File

@@ -3744,6 +3744,11 @@ tailwind-merge@^3.3.1:
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz#a7e7db7c714f6020319e626ecfb7e7dac8393a4b"
integrity sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==
tailwindcss-animate@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
integrity sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==
tailwindcss@^3.1, tailwindcss@^3.3.2:
version "3.4.17"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63"