система инит
This commit is contained in:
4631
package-lock.json
generated
Normal file
4631
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -11,13 +11,31 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-switch": "^1.2.5",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
"@reduxjs/toolkit": "^1.9.5",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
|
"@types/react-grid-layout": "^1.3.5",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"redux": "^4.2.1"
|
"react-grid-layout": "^1.5.2",
|
||||||
|
"redux": "^4.2.1",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.0.37",
|
"@types/react": "^18.0.37",
|
||||||
|
"@types/react-beautiful-dnd": "^13.1.8",
|
||||||
"@types/react-dom": "^18.0.11",
|
"@types/react-dom": "^18.0.11",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||||
"@typescript-eslint/parser": "^5.59.0",
|
"@typescript-eslint/parser": "^5.59.0",
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { FC } from "react";
|
import { FC } from "react";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { TemplateManager } from "../components/TemplateManager/TemplateManager";
|
||||||
import { Layout } from "@/app/Layout";
|
|
||||||
import { Home, NoMatch } from "@/pages";
|
|
||||||
|
|
||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-background">
|
||||||
<Routes>
|
<TemplateManager />
|
||||||
<Route path="/" element={<Layout />}>
|
</div>
|
||||||
<Route index element={<Home />} />
|
|
||||||
<Route path="*" element={<NoMatch />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,59 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--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-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
--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%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 84% 4.9%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ const DualSpreadsheet: FC<DualSpreadsheetProps> = ({ templateData = {} }) => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Загружаем данные в движок
|
||||||
|
Object.entries(templateData.Report).forEach(([cellName, value]) => {
|
||||||
|
const match = cellName.match(/^([A-Z]+)(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const col = match[1].charCodeAt(0) - 65;
|
||||||
|
const row = parseInt(match[2]) - 1;
|
||||||
|
if (row >= 0 && row < 20 && col >= 0 && col < 10) {
|
||||||
|
multiSheetEngine.setCellValue('Report', row, col, String(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [templateData]);
|
}, [templateData]);
|
||||||
|
|
||||||
|
|||||||
319
src/components/TemplateManager/ElementConstructor.tsx
Normal file
319
src/components/TemplateManager/ElementConstructor.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import { Calendar, CheckSquare, ChevronDown, Edit, FileText, Hash, Plus, Trash2, Type } from 'lucide-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ElementType, TemplateElement } from '../../types/template';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardHeader } from '../ui/card';
|
||||||
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
|
||||||
|
interface ElementFormProps {
|
||||||
|
formData: Partial<TemplateElement>;
|
||||||
|
setFormData: React.Dispatch<React.SetStateAction<Partial<TemplateElement>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELEMENT_TYPES: Array<{ type: ElementType; label: string; icon: React.ReactNode }> = [
|
||||||
|
{ type: 'text', label: 'Текстовое поле', icon: <Type className="h-4 w-4" /> },
|
||||||
|
{ type: 'textarea', label: 'Многострочный текст', icon: <FileText className="h-4 w-4" /> },
|
||||||
|
{ type: 'number', label: 'Число', icon: <Hash className="h-4 w-4" /> },
|
||||||
|
{ type: 'select', label: 'Выпадающий список', icon: <ChevronDown className="h-4 w-4" /> },
|
||||||
|
{ type: 'radio', label: 'Радиокнопки', icon: <CheckSquare className="h-4 w-4" /> },
|
||||||
|
{ type: 'checkbox', label: 'Чекбокс', icon: <CheckSquare className="h-4 w-4" /> },
|
||||||
|
{ type: 'date', label: 'Дата', icon: <Calendar className="h-4 w-4" /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ElementForm: React.FC<ElementFormProps> = ({ formData, setFormData }) => {
|
||||||
|
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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Тип элемента</label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={value => setFormData(prev => ({ ...prev, type: value as ElementType }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ELEMENT_TYPES.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-2">
|
||||||
|
<label className="text-sm font-medium">Ячейка отчета</label>
|
||||||
|
<Input
|
||||||
|
placeholder="A1, B5, C10..."
|
||||||
|
value={formData.targetCell}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, targetCell: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Имя поля</label>
|
||||||
|
<Input
|
||||||
|
placeholder="field_name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Подпись</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Название поля"
|
||||||
|
value={formData.label}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, label: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Подсказка</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите значение..."
|
||||||
|
value={formData.placeholder}
|
||||||
|
onChange={e => setFormData(prev => ({ ...prev, placeholder: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="required"
|
||||||
|
checked={formData.required}
|
||||||
|
onCheckedChange={checked => setFormData(prev => ({ ...prev, required: !!checked }))}
|
||||||
|
/>
|
||||||
|
<label htmlFor="required" className="text-sm font-medium">
|
||||||
|
Обязательное поле
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsOptions && (
|
||||||
|
<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="h-4 w-4 mr-1" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||||
|
{formData.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ElementConstructorProps {
|
||||||
|
elements: TemplateElement[];
|
||||||
|
onElementAdd: (element: TemplateElement) => void;
|
||||||
|
onElementUpdate: (elementId: string, updates: Partial<TemplateElement>) => void;
|
||||||
|
onElementDelete: (elementId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ElementConstructor: React.FC<ElementConstructorProps> = ({
|
||||||
|
elements,
|
||||||
|
onElementAdd,
|
||||||
|
onElementUpdate,
|
||||||
|
onElementDelete,
|
||||||
|
}) => {
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [editingElement, setEditingElement] = useState<TemplateElement | null>(null);
|
||||||
|
const [formData, setFormData] = useState<Partial<TemplateElement>>({
|
||||||
|
type: 'text',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
targetCell: '',
|
||||||
|
placeholder: '',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
type: 'text',
|
||||||
|
name: '',
|
||||||
|
label: '',
|
||||||
|
targetCell: '',
|
||||||
|
placeholder: '',
|
||||||
|
required: false,
|
||||||
|
options: [],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddElement = () => {
|
||||||
|
if (!formData.name || !formData.label || !formData.targetCell) return;
|
||||||
|
|
||||||
|
const newElement: TemplateElement = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
type: formData.type as ElementType,
|
||||||
|
name: formData.name,
|
||||||
|
label: formData.label,
|
||||||
|
targetCell: formData.targetCell,
|
||||||
|
placeholder: formData.placeholder,
|
||||||
|
required: formData.required,
|
||||||
|
options: formData.options,
|
||||||
|
};
|
||||||
|
|
||||||
|
onElementAdd(newElement);
|
||||||
|
resetForm();
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditElement = (element: TemplateElement) => {
|
||||||
|
setEditingElement(element);
|
||||||
|
setFormData(element);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateElement = () => {
|
||||||
|
if (!editingElement || !formData.name || !formData.label || !formData.targetCell) return;
|
||||||
|
|
||||||
|
onElementUpdate(editingElement.id, formData);
|
||||||
|
setEditingElement(null);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingElement(null);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Конструктор элементов</h3>
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size="sm" onClick={() => resetForm()}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить элемент
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Добавить элемент</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ElementForm formData={formData} setFormData={setFormData} />
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddElement}>Добавить</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2 -mr-2">
|
||||||
|
{elements.map(element => (
|
||||||
|
<Card key={element.id} className="mb-4">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{ELEMENT_TYPES.find(t => t.type === element.type)?.icon}
|
||||||
|
<p className="font-semibold">{element.label}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Dialog
|
||||||
|
open={editingElement?.id === element.id}
|
||||||
|
onOpenChange={isOpen => !isOpen && handleCancelEdit()}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEditElement(element)}>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Редактировать элемент</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<ElementForm formData={formData} setFormData={setFormData} />
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button variant="outline" onClick={handleCancelEdit}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpdateElement}>Сохранить</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-600"
|
||||||
|
onClick={() => onElementDelete(element.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>
|
||||||
|
<strong>Тип:</strong> {ELEMENT_TYPES.find(t => t.type === element.type)?.label}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Имя:</strong> {element.name}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Ячейка:</strong> {element.targetCell}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
194
src/components/TemplateManager/TemplateEditor.tsx
Normal file
194
src/components/TemplateManager/TemplateEditor.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { Template, TemplateElement } from '../../types/template';
|
||||||
|
import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { ElementConstructor } from './ElementConstructor';
|
||||||
|
|
||||||
|
interface TemplateEditorProps {
|
||||||
|
template: Template;
|
||||||
|
onSave: (template: Template) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||||
|
template,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onBack,
|
||||||
|
}) => {
|
||||||
|
const [editedTemplate, setEditedTemplate] = useState<Template>(template);
|
||||||
|
const [excelData, setExcelData] = useState<Record<string, any>>(template.excelData || {});
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||||
|
const workbook = XLSX.read(data, { type: 'array' });
|
||||||
|
|
||||||
|
// Читаем первый лист
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||||
|
|
||||||
|
// Преобразуем в формат для нашей системы
|
||||||
|
const formattedData: Record<string, any> = {};
|
||||||
|
|
||||||
|
(jsonData as any[][]).forEach((row, rowIndex) => {
|
||||||
|
row.forEach((cell, colIndex) => {
|
||||||
|
if (cell !== undefined && cell !== null && cell !== '') {
|
||||||
|
const cellName = String.fromCharCode(65 + colIndex) + (rowIndex + 1);
|
||||||
|
formattedData[cellName] = cell;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setExcelData(formattedData);
|
||||||
|
setEditedTemplate(prev => ({
|
||||||
|
...prev,
|
||||||
|
excelFile: file,
|
||||||
|
excelData: formattedData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Принудительно обновляем данные в таблице
|
||||||
|
setTimeout(() => {
|
||||||
|
setExcelData({...formattedData});
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка при чтении Excel файла:', error);
|
||||||
|
alert('Ошибка при чтении Excel файла');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElementAdd = (element: TemplateElement) => {
|
||||||
|
setEditedTemplate(prev => ({
|
||||||
|
...prev,
|
||||||
|
elements: [...prev.elements, element],
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElementUpdate = (elementId: string, updates: Partial<TemplateElement>) => {
|
||||||
|
setEditedTemplate(prev => ({
|
||||||
|
...prev,
|
||||||
|
elements: prev.elements.map(el =>
|
||||||
|
el.id === elementId ? { ...el, ...updates } : el
|
||||||
|
),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleElementDelete = (elementId: string) => {
|
||||||
|
setEditedTemplate(prev => ({
|
||||||
|
...prev,
|
||||||
|
elements: prev.elements.filter(el => el.id !== elementId),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(editedTemplate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-50">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-800">
|
||||||
|
Редактирование шаблона: {editedTemplate.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Настройте Excel шаблон и создайте элементы интерфейса
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} className="flex items-center gap-2">
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Основной контент */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Загрузка Excel и таблицы */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Панель загрузки Excel */}
|
||||||
|
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm font-medium text-gray-700">
|
||||||
|
Excel шаблон:
|
||||||
|
</div>
|
||||||
|
{editedTemplate.excelFile ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span>{editedTemplate.excelFile.name}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500">Файл не загружен</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
{editedTemplate.excelFile ? 'Заменить файл' : 'Загрузить Excel'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблицы */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<DualSpreadsheet
|
||||||
|
templateData={{ Report: excelData }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Конструктор элементов */}
|
||||||
|
<div className="h-80 border-t border-gray-200 bg-white flex-shrink-0">
|
||||||
|
<ElementConstructor
|
||||||
|
elements={editedTemplate.elements}
|
||||||
|
onElementAdd={handleElementAdd}
|
||||||
|
onElementUpdate={handleElementUpdate}
|
||||||
|
onElementDelete={handleElementDelete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
229
src/components/TemplateManager/TemplateManager.tsx
Normal file
229
src/components/TemplateManager/TemplateManager.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { FileText, Plus, Settings, Upload } from 'lucide-react';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Template } from '../../types/template';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { TemplateEditor } from './TemplateEditor';
|
||||||
|
import { TemplateRenderer } from './TemplateRenderer';
|
||||||
|
|
||||||
|
interface TemplateManagerProps {
|
||||||
|
onTemplateSelect?: (template: Template) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateManager: React.FC<TemplateManagerProps> = ({ onTemplateSelect }) => {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
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 handleCreateTemplate = () => {
|
||||||
|
if (!newTemplateName.trim()) return;
|
||||||
|
|
||||||
|
const newTemplate: Template = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name: newTemplateName.trim(),
|
||||||
|
description: newTemplateDescription.trim(),
|
||||||
|
elements: [],
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
setTemplates(prev => [...prev, newTemplate]);
|
||||||
|
setSelectedTemplate(newTemplate);
|
||||||
|
setIsEditMode(true);
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setNewTemplateName('');
|
||||||
|
setNewTemplateDescription('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateUpdate = (updatedTemplate: Template) => {
|
||||||
|
setTemplates(prev => prev.map(t =>
|
||||||
|
t.id === updatedTemplate.id ? updatedTemplate : t
|
||||||
|
));
|
||||||
|
setSelectedTemplate(updatedTemplate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateDelete = (templateId: string) => {
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== templateId));
|
||||||
|
if (selectedTemplate?.id === templateId) {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setIsEditMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateSelect = (template: Template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setIsEditMode(false);
|
||||||
|
onTemplateSelect?.(template);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTemplate = (template: Template) => {
|
||||||
|
setSelectedTemplate(template);
|
||||||
|
setIsEditMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (selectedTemplate && !isEditMode) {
|
||||||
|
return (
|
||||||
|
<TemplateRenderer
|
||||||
|
template={selectedTemplate}
|
||||||
|
onBack={() => setSelectedTemplate(null)}
|
||||||
|
onEdit={() => setIsEditMode(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTemplate && isEditMode) {
|
||||||
|
return (
|
||||||
|
<TemplateEditor
|
||||||
|
template={selectedTemplate}
|
||||||
|
onSave={handleTemplateUpdate}
|
||||||
|
onCancel={() => setIsEditMode(false)}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
setIsEditMode(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Система протоколов</h1>
|
||||||
|
<p className="text-gray-600 mt-2">Управление шаблонами и создание отчетов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="flex items-center gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Создать шаблон
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Создать новый шаблон</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Название шаблона</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите название шаблона"
|
||||||
|
value={newTemplateName}
|
||||||
|
onChange={(e) => setNewTemplateName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Описание (опционально)</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Введите описание шаблона"
|
||||||
|
value={newTemplateDescription}
|
||||||
|
onChange={(e) => setNewTemplateDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsCreateDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateTemplate}
|
||||||
|
disabled={!newTemplateName.trim()}
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<FileText className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">Нет созданных шаблонов</h3>
|
||||||
|
<p className="text-gray-600 mb-6">Создайте первый шаблон для начала работы</p>
|
||||||
|
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Создать шаблон
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<CardTitle className="text-lg">{template.name}</CardTitle>
|
||||||
|
{template.description && (
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
{template.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditTemplate(template);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{template.excelFile ? 'Excel файл загружен' : 'Excel файл не загружен'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span>{template.elements.length} элементов интерфейса</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-2">
|
||||||
|
Создан: {template.createdAt.toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => handleTemplateSelect(template)}
|
||||||
|
>
|
||||||
|
Использовать
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditTemplate(template);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Настроить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
316
src/components/TemplateManager/TemplateRenderer.tsx
Normal file
316
src/components/TemplateManager/TemplateRenderer.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import { ArrowLeft, Send, Settings } from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ExportData, Template, TemplateElement } from '../../types/template';
|
||||||
|
import DualSpreadsheet from '../DualSpreadsheet/DualSpreadsheet';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
|
import { Checkbox } from '../ui/checkbox';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
|
import { Textarea } from '../ui/textarea';
|
||||||
|
|
||||||
|
interface TemplateRendererProps {
|
||||||
|
template: Template;
|
||||||
|
onBack: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplateRenderer: React.FC<TemplateRendererProps> = ({
|
||||||
|
template,
|
||||||
|
onBack,
|
||||||
|
onEdit,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [spreadsheetData, setSpreadsheetData] = useState<Record<string, any>>(template.excelData || {});
|
||||||
|
|
||||||
|
// Инициализация формы значениями по умолчанию
|
||||||
|
useEffect(() => {
|
||||||
|
const initialData: Record<string, any> = {};
|
||||||
|
template.elements.forEach(element => {
|
||||||
|
if (element.defaultValue) {
|
||||||
|
initialData[element.id] = element.defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setFormData(initialData);
|
||||||
|
}, [template.elements]);
|
||||||
|
|
||||||
|
// Обновление данных таблицы при изменении формы
|
||||||
|
useEffect(() => {
|
||||||
|
const updatedData = { ...template.excelData || {}, ...spreadsheetData };
|
||||||
|
|
||||||
|
template.elements.forEach(element => {
|
||||||
|
const value = formData[element.id];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
updatedData[element.targetCell] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSpreadsheetData(updatedData);
|
||||||
|
}, [formData, template.elements, template.excelData]);
|
||||||
|
|
||||||
|
const handleInputChange = (elementId: string, value: any) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[elementId]: value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Очищаем ошибку при изменении значения
|
||||||
|
if (errors[elementId]) {
|
||||||
|
setErrors(prev => {
|
||||||
|
const newErrors = { ...prev };
|
||||||
|
delete newErrors[elementId];
|
||||||
|
return newErrors;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
template.elements.forEach(element => {
|
||||||
|
if (element.required) {
|
||||||
|
const value = formData[element.id];
|
||||||
|
if (!value || (typeof value === 'string' && value.trim() === '')) {
|
||||||
|
newErrors[element.id] = `Поле "${element.label}" обязательно для заполнения`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем измененные ячейки из таблицы
|
||||||
|
const changedCells = (window as any).getChangedCells?.() || {};
|
||||||
|
|
||||||
|
// Создаем данные для экспорта
|
||||||
|
const exportData: ExportData = {
|
||||||
|
templateId: template.id,
|
||||||
|
templateName: template.name,
|
||||||
|
excelFile: template.excelFile!,
|
||||||
|
cellUpdates: {
|
||||||
|
...changedCells,
|
||||||
|
// Добавляем значения из формы
|
||||||
|
...template.elements.reduce((acc, element) => {
|
||||||
|
const value = formData[element.id];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
acc[element.targetCell] = { rawValue: value, calculatedValue: value };
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Данные для отправки на бэкенд:', exportData);
|
||||||
|
|
||||||
|
// Здесь будет отправка на бэкенд
|
||||||
|
alert(`Отчет "${template.name}" успешно сформирован!\n\nПроверьте консоль для подробностей.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFormElement = (element: TemplateElement) => {
|
||||||
|
const value = formData[element.id] || '';
|
||||||
|
const hasError = !!errors[element.id];
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
className: hasError ? 'border-red-500' : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (element.type) {
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="text"
|
||||||
|
placeholder={element.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleInputChange(element.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...commonProps}
|
||||||
|
placeholder={element.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleInputChange(element.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="number"
|
||||||
|
placeholder={element.placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleInputChange(element.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
{...commonProps}
|
||||||
|
type="date"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleInputChange(element.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={(val) => handleInputChange(element.id, val)}>
|
||||||
|
<SelectTrigger className={hasError ? 'border-red-500' : ''}>
|
||||||
|
<SelectValue placeholder={element.placeholder || 'Выберите значение'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{element.options?.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
return (
|
||||||
|
<RadioGroup value={value} onValueChange={(val) => handleInputChange(element.id, val)}>
|
||||||
|
{element.options?.map((option) => (
|
||||||
|
<div key={option.value} className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value={option.value} id={`${element.id}-${option.value}`} />
|
||||||
|
<label htmlFor={`${element.id}-${option.value}`} className="text-sm font-medium">
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={element.id}
|
||||||
|
checked={!!value}
|
||||||
|
onCheckedChange={(checked) => handleInputChange(element.id, checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor={element.id} className="text-sm font-medium">
|
||||||
|
{element.placeholder || 'Отметить'}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div>Неизвестный тип элемента: {element.type}</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!template.excelFile) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{template.name}</h1>
|
||||||
|
<p className="text-gray-600">Шаблон не настроен</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
Необходима настройка
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
Для использования этого шаблона необходимо загрузить Excel файл и настроить элементы интерфейса.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onEdit}>
|
||||||
|
Настроить шаблон
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex flex-col bg-gray-50">
|
||||||
|
{/* Заголовок */}
|
||||||
|
<div className="bg-white border-b border-gray-200 p-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-800">{template.name}</h1>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Заполните форму для создания отчета
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={onEdit}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Настроить
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} className="flex items-center gap-2">
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Сформировать отчет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex min-h-0">
|
||||||
|
{/* Левая панель - форма */}
|
||||||
|
<div className="w-80 border-r border-gray-200 bg-white flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">Параметры отчета</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{template.elements.map((element) => (
|
||||||
|
<div key={element.id} className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700 flex items-center gap-1">
|
||||||
|
{element.label}
|
||||||
|
{element.required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
{renderFormElement(element)}
|
||||||
|
{errors[element.id] && (
|
||||||
|
<p className="text-sm text-red-600">{errors[element.id]}</p>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Ячейка: {element.targetCell}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Правая панель - таблица */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<DualSpreadsheet
|
||||||
|
templateData={{ Report: spreadsheetData }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
src/components/ui/button.tsx
Normal file
40
src/components/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 }
|
||||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
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/components/ui/checkbox.tsx
Normal file
27
src/components/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/components/ui/dialog.tsx
Normal file
110
src/components/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/components/ui/input.tsx
Normal file
24
src/components/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 }
|
||||||
41
src/components/ui/radio-group.tsx
Normal file
41
src/components/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/components/ui/select.tsx
Normal file
148
src/components/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
|
||||||
|
}
|
||||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/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 }
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
46
src/types/template.ts
Normal file
46
src/types/template.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Типы элементов интерфейса
|
||||||
|
export type ElementType = 'text' | 'select' | 'radio' | 'checkbox' | 'number' | 'textarea' | 'date';
|
||||||
|
|
||||||
|
export interface ElementOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateElement {
|
||||||
|
id: string;
|
||||||
|
type: ElementType;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
targetCell: string; // например "A1", "B5"
|
||||||
|
options?: ElementOption[]; // для select и radio
|
||||||
|
required?: boolean;
|
||||||
|
defaultValue?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Типы шаблонов
|
||||||
|
export interface Template {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
excelFile?: File;
|
||||||
|
excelData?: Record<string, any>; // данные из Excel
|
||||||
|
elements: TemplateElement[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Типы для работы с данными
|
||||||
|
export interface TemplateData {
|
||||||
|
templateId: string;
|
||||||
|
values: Record<string, any>; // elementId -> value
|
||||||
|
calculatedCells?: Record<string, any>; // cellName -> value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Типы для экспорта
|
||||||
|
export interface ExportData {
|
||||||
|
templateId: string;
|
||||||
|
templateName: string;
|
||||||
|
excelFile: File;
|
||||||
|
cellUpdates: Record<string, any>; // cellName -> value
|
||||||
|
}
|
||||||
@@ -2,7 +2,48 @@
|
|||||||
export default {
|
export default {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
1
temp
Normal file
1
temp
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Так теперь продолжаем делать нашу систему. У нас должно быть изначально меню, где мы можем создавать шаблоны на создание протоколов. Мы даем в этом меню ему имя. Далее мы можем создавать шаблон, а это продгрузка excel файла, который будет шаблон (заполняться строчки левого меню отчета). И можем проводить рассчеты справа и изменять ячейки отчета. Также ниже от этих двух таблиц должны быть конструктор элементов которые тоже привяжутся к имени этого шаблона, там мы сможем создавать просто элементы который будут видны в интерфейсы, давать им имена и ячейку в которую будут вноситься данные при выборе в этих элементах (это будут например, элемент ввода текста, кнопки, радиокнопки, выпадающий список и тп). Тут важно интерфейс общий сделать чтобы удобно можно было добавлять новый вид элементов. Вот это как бы меню конструктор для создания отчетов, потом когда мы выбираем этот шаблон из списка, мы видим все наши элементы, мы туда вводим инофрмацию, у нас посчитывается ячейки что мы указали изменяемыми в левом отчете, и отправляется информация на бэк что за excel файл отчета, какие ячейки с каким значением туда надо вставить.
|
||||||
Reference in New Issue
Block a user