diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx
new file mode 100644
index 0000000..8e06b30
--- /dev/null
+++ b/frontend/src/pages/Admin.tsx
@@ -0,0 +1,323 @@
+import {
+ DeleteOutlined,
+ EditOutlined,
+ PlusOutlined,
+ UserOutlined,
+ ApiOutlined,
+} from '@ant-design/icons'
+import {
+ Badge,
+ Breadcrumb,
+ Button,
+ Col,
+ Divider,
+ Form,
+ Input,
+ Modal,
+ Popconfirm,
+ Row,
+ Select,
+ Space,
+ Switch,
+ Table,
+ Tag,
+ Tabs,
+ Typography,
+ message,
+} from 'antd'
+import { useState } from 'react'
+import {
+ useAdminUsers,
+ useCreateAdminUser,
+ useDeleteAdminUser,
+ usePlugins,
+ useUpdateAdminUser,
+ useUpdatePlugin,
+ type UserItem,
+} from '../api/admin'
+import { stripEmpty } from '../utils/form'
+
+// ── Plugins Tab ───────────────────────────────────────────────────────────────
+function PluginsTab() {
+ const { data: plugins, isLoading } = usePlugins()
+ const updatePlugin = useUpdatePlugin()
+
+ const toggle = async (name: string, enabled: boolean) => {
+ try {
+ await updatePlugin.mutateAsync({ name, enabled })
+ message.success(enabled ? `Плагин «${name}» включён` : `Плагин «${name}» отключён`)
+ } catch {
+ message.error('Ошибка при обновлении плагина')
+ }
+ }
+
+ const columns = [
+ {
+ title: 'Название',
+ key: 'display_name',
+ render: (_: unknown, row: (typeof plugins)[0]) => (
+
+ {row.display_name}
+
+ {row.name}
+
+
+ ),
+ },
+ {
+ title: 'Описание',
+ dataIndex: 'description',
+ key: 'description',
+ render: (v: string | null) => v ?? '—',
+ },
+ {
+ title: 'Категории',
+ dataIndex: 'compatible_categories',
+ key: 'categories',
+ render: (cats: string[]) =>
+ cats.length === 0 ? (
+
Все
+ ) : (
+ cats.map((c) =>
{c})
+ ),
+ },
+ {
+ title: 'Статус',
+ key: 'enabled',
+ width: 100,
+ render: (_: unknown, row: (typeof plugins)[0]) => (
+
toggle(row.name, val)}
+ loading={updatePlugin.isPending}
+ checkedChildren="Вкл"
+ unCheckedChildren="Откл"
+ />
+ ),
+ },
+ ]
+
+ return (
+
+ )
+}
+
+// ── Users Tab ─────────────────────────────────────────────────────────────────
+function UsersTab() {
+ const { data: users, isLoading } = useAdminUsers()
+ const createUser = useCreateAdminUser()
+ const updateUser = useUpdateAdminUser()
+ const deleteUser = useDeleteAdminUser()
+
+ const [createModal, setCreateModal] = useState(false)
+ const [editTarget, setEditTarget] = useState(null)
+ const [form] = Form.useForm()
+
+ const openCreate = () => {
+ form.resetFields()
+ setEditTarget(null)
+ setCreateModal(true)
+ }
+
+ const openEdit = (user: UserItem) => {
+ form.resetFields()
+ form.setFieldsValue({
+ email: user.email ?? '',
+ full_name: user.full_name ?? '',
+ role: user.role,
+ is_active: user.is_active,
+ })
+ setEditTarget(user)
+ setCreateModal(true)
+ }
+
+ const handleSave = async () => {
+ const values = await form.validateFields()
+ const payload = stripEmpty(values)
+ try {
+ if (editTarget) {
+ await updateUser.mutateAsync({ id: editTarget.id, body: payload as any })
+ message.success('Пользователь обновлён')
+ } else {
+ await createUser.mutateAsync(payload as any)
+ message.success('Пользователь создан')
+ }
+ setCreateModal(false)
+ setEditTarget(null)
+ } catch (err: any) {
+ const detail = err?.response?.data?.detail
+ message.error(detail ?? 'Ошибка при сохранении')
+ }
+ }
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteUser.mutateAsync(id)
+ message.success('Пользователь удалён')
+ } catch (err: any) {
+ message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
+ }
+ }
+
+ const columns = [
+ {
+ title: 'Логин',
+ dataIndex: 'username',
+ key: 'username',
+ render: (v: string) => {v},
+ },
+ { title: 'Email', dataIndex: 'email', key: 'email', render: (v: string | null) => v ?? '—' },
+ {
+ title: 'Роль',
+ dataIndex: 'role',
+ key: 'role',
+ render: (v: string) => (
+ {v}
+ ),
+ },
+ {
+ title: 'Статус',
+ dataIndex: 'is_active',
+ key: 'is_active',
+ render: (v: boolean) =>
+ v ? : ,
+ },
+ {
+ title: '',
+ key: 'actions',
+ width: 100,
+ render: (_: unknown, row: UserItem) => (
+
+ } onClick={() => openEdit(row)} />
+ handleDelete(row.id)}
+ >
+ } />
+
+
+ ),
+ },
+ ]
+
+ return (
+ <>
+
+ } onClick={openCreate}>
+ Создать пользователя
+
+
+
+
+
+ { setCreateModal(false); setEditTarget(null) }}
+ onOk={handleSave}
+ confirmLoading={createUser.isPending || updateUser.isPending}
+ okText={editTarget ? 'Сохранить' : 'Создать'}
+ >
+
+
+
+
+
+
+ >
+ )}
+ {editTarget && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {editTarget && (
+
+
+
+
+
+ )}
+
+
+
+ >
+ )
+}
+
+// ── Main Page ─────────────────────────────────────────────────────────────────
+export function AdminPage() {
+ return (
+
+
+
+ Администрирование
+
+
+
+ Плагины
+
+ ),
+ children: ,
+ },
+ {
+ key: 'users',
+ label: (
+
+ Пользователи
+
+ ),
+ children: ,
+ },
+ ]}
+ />
+
+ )
+}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 7b9248e..f2319dc 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -16,7 +16,7 @@ export function LoginPage() {
setError(null)
try {
const { data } = await api.post('/api/v1/auth/login', values)
- setTokens(data.access_token, data.refresh_token, values.username)
+ setTokens(data.access_token, data.refresh_token, data.username || values.username, data.role || 'operator')
navigate('/objects')
} catch {
setError('Неверный логин или пароль')
diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx
index d577e86..14f8ce3 100644
--- a/frontend/src/pages/ObjectDetail.tsx
+++ b/frontend/src/pages/ObjectDetail.tsx
@@ -2,6 +2,7 @@ import {
DeleteOutlined,
EditOutlined,
FileTextOutlined,
+ GoogleOutlined,
PlayCircleOutlined,
PlusOutlined,
SyncOutlined,
@@ -44,6 +45,7 @@ import {
useObject,
usePreviewCSV,
useSyncObject,
+ useSyncSheets,
type DeviceImportPreview,
type DeviceItem,
} from '../api/objects'
@@ -64,6 +66,7 @@ export function ObjectDetailPage() {
const { data: racks } = useRacks(objId)
const { data: zones } = useZones(objId)
const syncMutation = useSyncObject(objId)
+ const syncSheetsMutation = useSyncSheets(objId)
const { data: actions } = useActions()
const createTask = useCreateTask()
const createDevice = useCreateDevice(objId)
@@ -81,6 +84,7 @@ export function ObjectDetailPage() {
const [editDevice, setEditDevice] = useState(null)
const [csvModal, setCsvModal] = useState(false)
const [rackModal, setRackModal] = useState(false)
+ const [sheetsModal, setSheetsModal] = useState(false)
const [csvFile, setCsvFile] = useState(null)
const [csvPreview, setCsvPreview] = useState(null)
const [searchText, setSearchText] = useState('')
@@ -89,6 +93,7 @@ export function ObjectDetailPage() {
const [deviceForm] = Form.useForm()
const [rackForm] = Form.useForm()
const [zoneForm] = Form.useForm()
+ const [sheetsForm] = Form.useForm()
const [selectedAction, setSelectedAction] = useState('')
const currentAction = actions?.find((a) => a.name === selectedAction)
@@ -130,6 +135,23 @@ export function ObjectDetailPage() {
}
}
+ const handleSyncSheets = async () => {
+ try {
+ const values = await sheetsForm.validateFields()
+ const result = await syncSheetsMutation.mutateAsync({
+ spreadsheet_id: values.spreadsheet_id,
+ sheet_name: values.sheet_name || undefined,
+ })
+ message.success(`Google Sheets: +${result.created} создано, ${result.updated} обновлено`)
+ setSheetsModal(false)
+ sheetsForm.resetFields()
+ } catch (err: any) {
+ const detail = err?.response?.data?.detail
+ if (detail) message.error(detail)
+ // validation errors are shown inline — no extra message needed
+ }
+ }
+
const openAddDevice = () => {
deviceForm.resetFields()
setEditDevice(null)
@@ -473,6 +495,9 @@ export function ObjectDetailPage() {
} onClick={() => setCsvModal(true)}>
Импорт CSV
+ } onClick={() => setSheetsModal(true)}>
+ Google Sheets
+
} onClick={openAddDevice}>
Добавить устройство
@@ -788,6 +813,37 @@ export function ObjectDetailPage() {
+ {/* ── Google Sheets Sync Modal ── */}
+ { setSheetsModal(false); sheetsForm.resetFields() }}
+ onOk={handleSyncSheets}
+ confirmLoading={syncSheetsMutation.isPending}
+ okText="Синхронизировать"
+ >
+
+
+
+
+
+
+
+
+ Ожидаемые столбцы: ip, hostname, category, role, model, mac, notes
+
+
+
{/* ── Task Modal ── */}
void
+ role: string | null
+ setTokens: (access: string, refresh: string, username: string, role: string) => void
clearTokens: () => void
}
@@ -15,10 +16,11 @@ export const useAuthStore = create()(
accessToken: null,
refreshToken: null,
username: null,
- setTokens: (access, refresh, username) =>
- set({ accessToken: access, refreshToken: refresh, username }),
+ role: null,
+ setTokens: (access, refresh, username, role) =>
+ set({ accessToken: access, refreshToken: refresh, username, role }),
clearTokens: () =>
- set({ accessToken: null, refreshToken: null, username: null }),
+ set({ accessToken: null, refreshToken: null, username: null, role: null }),
}),
{ name: 'fleet-auth' }
)