This commit is contained in:
jze9
2026-07-21 16:11:09 +05:00
commit 78862296c4
94 changed files with 6090 additions and 0 deletions

2
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-slim AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS dev
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
FROM base AS build
COPY . .
RUN npm run build
FROM nginx:alpine AS prod
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Инвентаризация</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri /index.html;
}
}

1780
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
frontend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "inventory-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.3",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.82.0",
"react-router": "^8.2.0",
"tailwindcss": "^4.3.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,70 @@
const API_BASE = '/api/v1'
const TOKEN_STORAGE_KEY = 'inventory_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_STORAGE_KEY)
}
export function setToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_STORAGE_KEY, token)
else localStorage.removeItem(TOKEN_STORAGE_KEY)
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers = new Headers(options.headers)
if (token) headers.set('Authorization', `Bearer ${token}`)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const res = await fetch(`${API_BASE}${path}`, { ...options, headers })
if (!res.ok) {
let detail = res.statusText
try {
const data = await res.json()
if (typeof data.detail === 'string') detail = data.detail
} catch {
// response had no JSON body
}
throw new ApiError(res.status, detail)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
get: <T,>(path: string) => request<T>(path),
post: <T,>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body ?? {}),
}),
put: <T,>(path: string, body?: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body ?? {}) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
export async function login(username: string, password: string): Promise<string> {
const body = new URLSearchParams({ username, password })
const res = await fetch(`${API_BASE}/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) {
throw new ApiError(res.status, 'Неверный логин или пароль')
}
const data = (await res.json()) as { access_token: string }
return data.access_token
}

61
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,61 @@
export interface Photo {
id: string
url: string
content_type: string
position: number
}
export interface BoxRef {
id: string
name: string
}
export interface BoxSummary {
id: string
name: string
}
export interface ObjectSummary {
id: string
inventory_number: string
name: string
}
export interface InventoryObject {
id: string
inventory_number: string
name: string
description: string | null
box_id: string | null
box: BoxRef | null
created_at: string
updated_at: string
qr_code_url: string
photos: Photo[]
}
export interface ObjectInput {
inventory_number: string
name: string
description?: string | null
box_id?: string | null
}
export interface Box {
id: string
name: string
description: string | null
parent_box_id: string | null
created_at: string
updated_at: string
qr_code_url: string
ancestors: BoxSummary[]
children: BoxSummary[]
objects: ObjectSummary[]
}
export interface BoxInput {
name: string
description?: string | null
parent_box_id?: string | null
}

View File

@@ -0,0 +1,38 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
import { getToken, login as apiLogin, setToken as persistToken } from '../api/client'
interface AuthContextValue {
isAuthenticated: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setTokenState] = useState<string | null>(() => getToken())
const value = useMemo<AuthContextValue>(
() => ({
isAuthenticated: !!token,
login: async (username: string, password: string) => {
const newToken = await apiLogin(username, password)
persistToken(newToken)
setTokenState(newToken)
},
logout: () => {
persistToken(null)
setTokenState(null)
},
}),
[token],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}

View File

@@ -0,0 +1,13 @@
import type { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router'
import { useAuth } from './AuthContext'
export function RequireAuth({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth()
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,14 @@
import { Link } from 'react-router'
import type { BoxSummary } from '../api/types'
export function BoxCard({ box, linkBase = '/boxes' }: { box: BoxSummary; linkBase?: string }) {
return (
<Link
to={`${linkBase}/${box.id}`}
className="flex items-center gap-2 rounded-lg border border-slate-200 p-3 transition hover:border-slate-400 dark:border-slate-800 dark:hover:border-slate-600"
>
<span aria-hidden>📦</span>
<span className="font-medium text-slate-900 dark:text-slate-100">{box.name}</span>
</Link>
)
}

View File

@@ -0,0 +1,26 @@
import { Link } from 'react-router'
import type { BoxSummary } from '../api/types'
export function Breadcrumbs({
ancestors,
current,
basePath = '/boxes',
}: {
ancestors: BoxSummary[]
current: string
basePath?: string
}) {
return (
<nav className="flex flex-wrap items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
{ancestors.map((box) => (
<span key={box.id} className="flex items-center gap-1">
<Link to={`${basePath}/${box.id}`} className="hover:underline">
{box.name}
</Link>
<span>/</span>
</span>
))}
<span className="font-medium text-slate-900 dark:text-slate-100">{current}</span>
</nav>
)
}

View File

@@ -0,0 +1,23 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'secondary' | 'danger'
const VARIANT_CLASSES: Record<Variant, string> = {
primary: 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200',
secondary:
'bg-transparent text-slate-900 border border-slate-300 hover:bg-slate-100 dark:text-slate-100 dark:border-slate-700 dark:hover:bg-slate-800',
danger: 'bg-red-600 text-white hover:bg-red-700',
}
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
}
export function Button({ variant = 'primary', className = '', ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition disabled:opacity-50 disabled:cursor-not-allowed ${VARIANT_CLASSES[variant]} ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,35 @@
import { NavLink, Outlet } from 'react-router'
import { useAuth } from '../auth/AuthContext'
import { Button } from './Button'
const linkClass = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${
isActive
? 'bg-slate-900 text-white dark:bg-white dark:text-slate-900'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`
export function AdminLayout() {
const { logout } = useAuth()
return (
<div className="mx-auto flex min-h-svh max-w-4xl flex-col">
<header className="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800">
<nav className="flex gap-1">
<NavLink to="/admin/objects" className={linkClass}>
Объекты
</NavLink>
<NavLink to="/admin/boxes" className={linkClass}>
Ящики
</NavLink>
</nav>
<Button variant="secondary" onClick={logout}>
Выйти
</Button>
</header>
<main className="flex-1 p-4">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,14 @@
import { Link } from 'react-router'
import type { ObjectSummary } from '../api/types'
export function ObjectCard({ object, linkBase = '/objects' }: { object: ObjectSummary; linkBase?: string }) {
return (
<Link
to={`${linkBase}/${object.id}`}
className="flex flex-col gap-1 rounded-lg border border-slate-200 p-3 transition hover:border-slate-400 dark:border-slate-800 dark:hover:border-slate-600"
>
<span className="font-medium text-slate-900 dark:text-slate-100">{object.name}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{object.inventory_number}</span>
</Link>
)
}

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
import type { Photo } from '../api/types'
export function PhotoGallery({ photos }: { photos: Photo[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
if (photos.length === 0) {
return <p className="text-sm text-slate-500 dark:text-slate-400">Фото не добавлены</p>
}
return (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{photos.map((photo, index) => (
<button
key={photo.id}
onClick={() => setOpenIndex(index)}
className="aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800"
>
<img src={photo.url} alt="" className="h-full w-full object-cover" loading="lazy" />
</button>
))}
</div>
{openIndex !== null && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setOpenIndex(null)}
>
<img
src={photos[openIndex].url}
alt=""
className="max-h-full max-w-full rounded-lg object-contain"
/>
</div>
)}
</>
)
}

View File

@@ -0,0 +1,53 @@
import { useRef, useState } from 'react'
import type { Photo } from '../api/types'
import { useDeletePhoto, useUploadPhoto } from '../hooks/useObjects'
export function PhotoUploader({ objectId, photos }: { objectId: string; photos: Photo[] }) {
const uploadPhoto = useUploadPhoto(objectId)
const deletePhoto = useDeletePhoto(objectId)
const fileInputRef = useRef<HTMLInputElement>(null)
const [error, setError] = useState<string | null>(null)
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
setError(null)
for (const file of Array.from(files)) {
try {
await uploadPhoto.mutateAsync(file)
} catch {
setError('Не удалось загрузить одно или несколько фото')
}
}
if (fileInputRef.current) fileInputRef.current.value = ''
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{photos.map((photo) => (
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800">
<img src={photo.url} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => deletePhoto.mutate(photo.id)}
className="absolute top-1 right-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
>
Удалить
</button>
</div>
))}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
onChange={(e) => handleFiles(e.target.files)}
className="block text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-900"
/>
{uploadPhoto.isPending && <p className="text-sm text-slate-500">Загрузка...</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { Button } from './Button'
function handlePrint(url: string, title: string) {
const win = window.open('', '_blank', 'width=400,height=500')
if (!win) return
win.document.write(
`<html><head><title>${title}</title></head><body style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif">` +
`<img src="${url}" onload="window.print()" style="width:280px;height:280px" />` +
`<p style="margin-top:12px">${title}</p>` +
`</body></html>`,
)
win.document.close()
}
export function QrCodeCard({ url, title, fileName }: { url: string; title: string; fileName: string }) {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-slate-200 p-4 dark:border-slate-800">
<img src={url} alt={`QR-код: ${title}`} className="h-40 w-40" />
<div className="flex gap-2">
<a href={url} download={fileName}>
<Button type="button" variant="secondary">
Скачать
</Button>
</a>
<Button type="button" variant="secondary" onClick={() => handlePrint(url, title)}>
Печать
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import type { Box, BoxInput, BoxSummary } from '../api/types'
export function useBoxesList(search: string, rootOnly = false) {
const params = new URLSearchParams()
if (search) params.set('search', search)
if (rootOnly) params.set('root_only', 'true')
const qs = params.toString()
return useQuery({
queryKey: ['boxes', 'list', search, rootOnly],
queryFn: () => api.get<BoxSummary[]>(`/boxes${qs ? `?${qs}` : ''}`),
})
}
export function useBox(id: string | undefined) {
return useQuery({
queryKey: ['boxes', id],
queryFn: () => api.get<Box>(`/boxes/${id}`),
enabled: !!id,
})
}
export function useCreateBox() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: BoxInput) => api.post<Box>('/boxes', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}
export function useUpdateBox(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Partial<BoxInput>) => api.put<Box>(`/boxes/${id}`, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}
export function useDeleteBox() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/boxes/${id}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['boxes'] }),
})
}

View File

@@ -0,0 +1,69 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '../api/client'
import type { InventoryObject, ObjectInput, Photo } from '../api/types'
export function useObjectsList(search: string) {
return useQuery({
queryKey: ['objects', search],
queryFn: () =>
api.get<InventoryObject[]>(`/objects${search ? `?search=${encodeURIComponent(search)}` : ''}`),
})
}
export function useObject(id: string | undefined) {
return useQuery({
queryKey: ['objects', id],
queryFn: () => api.get<InventoryObject>(`/objects/${id}`),
enabled: !!id,
})
}
export function useCreateObject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ObjectInput) => api.post<InventoryObject>('/objects', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects'] }),
})
}
export function useUpdateObject(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: Partial<ObjectInput>) => api.put<InventoryObject>(`/objects/${id}`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objects'] })
queryClient.invalidateQueries({ queryKey: ['boxes'] })
},
})
}
export function useDeleteObject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/objects/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['objects'] })
queryClient.invalidateQueries({ queryKey: ['boxes'] })
},
})
}
export function useUploadPhoto(objectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post<Photo>(`/objects/${objectId}/photos`, form)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects', objectId] }),
})
}
export function useDeletePhoto(objectId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (photoId: string) => api.delete(`/photos/${photoId}`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['objects', objectId] }),
})
}

18
frontend/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
@import "tailwindcss";
@theme {
--font-sans: system-ui, "Segoe UI", Roboto, sans-serif;
}
:root {
color-scheme: light dark;
}
body {
margin: 0;
@apply bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100;
}
#root {
min-height: 100svh;
}

23
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { AuthProvider } from './auth/AuthContext'
import './index.css'
import { AppRoutes } from './router'
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 10_000 } },
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,71 @@
import { useState, type FormEvent } from 'react'
import { Navigate, useLocation, useNavigate } from 'react-router'
import { useAuth } from '../auth/AuthContext'
import { Button } from '../components/Button'
export function LoginPage() {
const { isAuthenticated, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
if (isAuthenticated) {
const from = (location.state as { from?: Location })?.from?.pathname ?? '/admin/objects'
return <Navigate to={from} replace />
}
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
await login(username, password)
navigate('/admin/objects', { replace: true })
} catch {
setError('Неверный логин или пароль')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="flex min-h-svh items-center justify-center p-4">
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-4">
<h1 className="text-xl font-semibold">Вход в админ-панель</h1>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="username">
Логин
</label>
<input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoFocus
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium" htmlFor="password">
Пароль
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting ? 'Вход...' : 'Войти'}
</Button>
</form>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { Link, useNavigate, useParams } from 'react-router'
import { Breadcrumbs } from '../../components/Breadcrumbs'
import { Button } from '../../components/Button'
import { QrCodeCard } from '../../components/QrCodeCard'
import { useBox, useBoxesList, useDeleteBox } from '../../hooks/useBoxes'
import { useDeleteObject } from '../../hooks/useObjects'
export function BoxBrowsePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const deleteBox = useDeleteBox()
const deleteObject = useDeleteObject()
const { data: box, isLoading: boxLoading } = useBox(id)
const { data: rootBoxes, isLoading: rootLoading } = useBoxesList('', true)
if (id) {
if (boxLoading) return <p className="text-slate-500">Загрузка...</p>
if (!box) return <p className="text-slate-500">Ящик не найден</p>
return (
<div className="space-y-6">
<Breadcrumbs ancestors={box.ancestors} current={box.name} basePath="/admin/boxes" />
<div className="flex items-center justify-between gap-2">
<div>
<h1 className="text-xl font-semibold">{box.name}</h1>
{box.description && <p className="text-sm text-slate-500 dark:text-slate-400">{box.description}</p>}
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/boxes/${box.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (!confirm(`Удалить ящик «${box.name}»?`)) return
deleteBox.mutate(box.id, {
onSuccess: () => navigate(box.parent_box_id ? `/admin/boxes/${box.parent_box_id}` : '/admin/boxes'),
})
}}
>
Удалить
</Button>
</div>
</div>
<QrCodeCard url={`/api/v1/boxes/${box.id}/qrcode.png`} title={box.name} fileName={`${box.name}.png`} />
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Вложенные ящики</h2>
<Link to={`/admin/boxes/new?parent=${box.id}`} className="text-sm text-slate-600 hover:underline dark:text-slate-300">
+ Добавить ящик
</Link>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{box.children.map((child) => (
<Link
key={child.id}
to={`/admin/boxes/${child.id}`}
className="flex items-center gap-2 py-2 hover:underline"
>
📦 {child.name}
</Link>
))}
{box.children.length === 0 && <p className="py-2 text-sm text-slate-500 dark:text-slate-400">Нет вложенных ящиков</p>}
</div>
</section>
<section className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Объекты</h2>
<Link to={`/admin/objects/new`} className="text-sm text-slate-600 hover:underline dark:text-slate-300">
+ Добавить объект
</Link>
</div>
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{box.objects.map((object) => (
<div key={object.id} className="flex items-center justify-between gap-2 py-2">
<div>
<p className="font-medium">{object.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">{object.inventory_number}</p>
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/objects/${object.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (confirm(`Удалить объект «${object.name}»?`)) deleteObject.mutate(object.id)
}}
>
Удалить
</Button>
</div>
</div>
))}
{box.objects.length === 0 && <p className="py-2 text-sm text-slate-500 dark:text-slate-400">В этом ящике пока пусто</p>}
</div>
</section>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Ящики</h1>
<Link to="/admin/boxes/new">
<Button>Добавить</Button>
</Link>
</div>
{rootLoading && <p className="text-slate-500">Загрузка...</p>}
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{rootBoxes?.map((rootBox) => (
<Link key={rootBox.id} to={`/admin/boxes/${rootBox.id}`} className="flex items-center gap-2 py-3 hover:underline">
📦 {rootBox.name}
</Link>
))}
{rootBoxes?.length === 0 && !rootLoading && <p className="py-4 text-slate-500">Ящиков пока нет</p>}
</div>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate, useParams, useSearchParams } from 'react-router'
import { z } from 'zod'
import { ApiError } from '../../api/client'
import { Button } from '../../components/Button'
import { useBox, useBoxesList, useCreateBox, useUpdateBox } from '../../hooks/useBoxes'
const schema = z.object({
name: z.string().min(1, 'Обязательное поле'),
description: z.string(),
parent_box_id: z.string(),
})
type FormValues = z.infer<typeof schema>
export function BoxFormPage() {
const { id } = useParams<{ id: string }>()
const [searchParams] = useSearchParams()
const isEditing = !!id
const navigate = useNavigate()
const { data: box } = useBox(id)
const { data: allBoxes } = useBoxesList('')
const createBox = useCreateBox()
const updateBox = useUpdateBox(id ?? '')
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', description: '', parent_box_id: searchParams.get('parent') ?? '' },
})
useEffect(() => {
if (box) {
reset({
name: box.name,
description: box.description ?? '',
parent_box_id: box.parent_box_id ?? '',
})
}
}, [box, reset])
async function onSubmit(values: FormValues) {
const payload = {
name: values.name,
description: values.description || null,
parent_box_id: values.parent_box_id || null,
}
try {
if (isEditing) {
const updated = await updateBox.mutateAsync(payload)
navigate(`/admin/boxes/${updated.id}`)
} else {
const created = await createBox.mutateAsync(payload)
navigate(`/admin/boxes/${created.id}`)
}
} catch (err) {
if (err instanceof ApiError) {
setError('root', { message: err.message })
} else {
setError('root', { message: 'Не удалось сохранить ящик' })
}
}
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-xl font-semibold">{isEditing ? 'Изменить ящик' : 'Новый ящик'}</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Название</label>
<input
{...register('name')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Описание</label>
<textarea
{...register('description')}
rows={3}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Родительский ящик</label>
<select
{...register('parent_box_id')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
>
<option value=""> верхний уровень </option>
{allBoxes
?.filter((b) => b.id !== id)
.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</div>
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button type="button" variant="secondary" onClick={() => navigate(-1)}>
Отмена
</Button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useNavigate, useParams } from 'react-router'
import { z } from 'zod'
import { api, ApiError } from '../../api/client'
import { Button } from '../../components/Button'
import { PhotoUploader } from '../../components/PhotoUploader'
import { QrCodeCard } from '../../components/QrCodeCard'
import { useBoxesList } from '../../hooks/useBoxes'
import { useCreateObject, useObject, useUpdateObject } from '../../hooks/useObjects'
interface PendingPhoto {
file: File
previewUrl: string
}
const schema = z.object({
inventory_number: z.string().min(1, 'Обязательное поле'),
name: z.string().min(1, 'Обязательное поле'),
description: z.string(),
box_id: z.string(),
})
type FormValues = z.infer<typeof schema>
export function ObjectFormPage() {
const { id } = useParams<{ id: string }>()
const isEditing = !!id
const navigate = useNavigate()
const { data: object } = useObject(id)
const { data: boxes } = useBoxesList('')
const createObject = useCreateObject()
const updateObject = useUpdateObject(id ?? '')
const [pendingPhotos, setPendingPhotos] = useState<PendingPhoto[]>([])
const pendingFileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
return () => {
pendingPhotos.forEach((p) => URL.revokeObjectURL(p.previewUrl))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function addPendingPhotos(files: FileList | null) {
if (!files || files.length === 0) return
const added = Array.from(files).map((file) => ({ file, previewUrl: URL.createObjectURL(file) }))
setPendingPhotos((prev) => [...prev, ...added])
if (pendingFileInputRef.current) pendingFileInputRef.current.value = ''
}
function removePendingPhoto(index: number) {
setPendingPhotos((prev) => {
URL.revokeObjectURL(prev[index].previewUrl)
return prev.filter((_, i) => i !== index)
})
}
const {
register,
handleSubmit,
reset,
setError,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { inventory_number: '', name: '', description: '', box_id: '' },
})
useEffect(() => {
if (object) {
reset({
inventory_number: object.inventory_number,
name: object.name,
description: object.description ?? '',
box_id: object.box_id ?? '',
})
}
}, [object, reset])
async function onSubmit(values: FormValues) {
const payload = {
inventory_number: values.inventory_number,
name: values.name,
description: values.description || null,
box_id: values.box_id || null,
}
try {
if (isEditing) {
await updateObject.mutateAsync(payload)
navigate('/admin/objects')
} else {
const created = await createObject.mutateAsync(payload)
const failedFiles: string[] = []
for (const { file } of pendingPhotos) {
const form = new FormData()
form.append('file', file)
try {
await api.post(`/objects/${created.id}/photos`, form)
} catch {
failedFiles.push(file.name)
}
}
if (failedFiles.length > 0) {
// The object itself is already saved; surface the failure before leaving the page.
alert(`Объект сохранён, но не удалось загрузить: ${failedFiles.join(', ')}. Попробуйте ещё раз на странице объекта.`)
}
navigate(`/admin/objects/${created.id}/edit`)
}
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setError('inventory_number', { message: 'Этот инвентарный номер уже используется' })
} else {
setError('root', { message: 'Не удалось сохранить объект' })
}
}
}
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-xl font-semibold">{isEditing ? 'Изменить объект' : 'Новый объект'}</h1>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium">Инвентарный номер</label>
<input
{...register('inventory_number')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.inventory_number && <p className="text-sm text-red-600">{errors.inventory_number.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Название</label>
<input
{...register('name')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{errors.name && <p className="text-sm text-red-600">{errors.name.message}</p>}
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Описание</label>
<textarea
{...register('description')}
rows={4}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium">Ящик</label>
<select
{...register('box_id')}
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
>
<option value=""> без ящика </option>
{boxes?.map((box) => (
<option key={box.id} value={box.id}>
{box.name}
</option>
))}
</select>
</div>
{!isEditing && (
<div className="space-y-1">
<label className="text-sm font-medium">Фотографии</label>
{pendingPhotos.length > 0 && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{pendingPhotos.map((photo, index) => (
<div
key={photo.previewUrl}
className="group relative aspect-square overflow-hidden rounded-lg border border-slate-200 dark:border-slate-800"
>
<img src={photo.previewUrl} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => removePendingPhoto(index)}
className="absolute top-1 right-1 rounded-full bg-black/60 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100"
>
Удалить
</button>
</div>
))}
</div>
)}
<input
ref={pendingFileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
multiple
onChange={(e) => addPendingPhotos(e.target.files)}
className="block text-sm text-slate-600 file:mr-3 file:rounded-md file:border-0 file:bg-slate-900 file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-white dark:file:text-slate-900"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">Загрузятся сразу после сохранения объекта</p>
</div>
)}
{errors.root && <p className="text-sm text-red-600">{errors.root.message}</p>}
<div className="flex gap-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button type="button" variant="secondary" onClick={() => navigate('/admin/objects')}>
Отмена
</Button>
</div>
</form>
{isEditing && object && (
<>
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Фотографии</h2>
<PhotoUploader objectId={object.id} photos={object.photos} />
</section>
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">QR-код</h2>
<QrCodeCard
url={`/api/v1/objects/${object.id}/qrcode.png`}
title={object.name}
fileName={`${object.inventory_number}.png`}
/>
</section>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useDeleteObject, useObjectsList } from '../../hooks/useObjects'
import { Button } from '../../components/Button'
export function ObjectListPage() {
const [search, setSearch] = useState('')
const { data: objects, isLoading } = useObjectsList(search)
const deleteObject = useDeleteObject()
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Объекты</h1>
<Link to="/admin/objects/new">
<Button>Добавить</Button>
</Link>
</div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по номеру или названию..."
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-900"
/>
{isLoading && <p className="text-slate-500">Загрузка...</p>}
<div className="divide-y divide-slate-200 dark:divide-slate-800">
{objects?.map((object) => (
<div key={object.id} className="flex items-center justify-between gap-2 py-3">
<div>
<p className="font-medium">{object.name}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
{object.inventory_number}
{object.box && ` · ${object.box.name}`}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Link to={`/admin/objects/${object.id}/edit`}>
<Button variant="secondary">Изменить</Button>
</Link>
<Button
variant="danger"
onClick={() => {
if (confirm(`Удалить объект «${object.name}»?`)) deleteObject.mutate(object.id)
}}
>
Удалить
</Button>
</div>
</div>
))}
{objects?.length === 0 && !isLoading && <p className="py-4 text-slate-500">Ничего не найдено</p>}
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import { useParams } from 'react-router'
import { useBox } from '../../hooks/useBoxes'
import { Breadcrumbs } from '../../components/Breadcrumbs'
import { BoxCard } from '../../components/BoxCard'
import { ObjectCard } from '../../components/ObjectCard'
export function BoxDetailPage() {
const { id } = useParams<{ id: string }>()
const { data: box, isLoading, isError } = useBox(id)
if (isLoading) {
return <p className="p-4 text-center text-slate-500">Загрузка...</p>
}
if (isError || !box) {
return <p className="p-4 text-center text-slate-500">Ящик не найден</p>
}
return (
<div className="mx-auto max-w-lg space-y-4 p-4">
<Breadcrumbs ancestors={box.ancestors} current={box.name} />
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{box.name}</h1>
{box.description && <p className="text-slate-700 dark:text-slate-300">{box.description}</p>}
{box.children.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Вложенные ящики</h2>
<div className="grid gap-2">
{box.children.map((child) => (
<BoxCard key={child.id} box={child} />
))}
</div>
</section>
)}
<section className="space-y-2">
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">Объекты</h2>
{box.objects.length === 0 ? (
<p className="text-sm text-slate-500 dark:text-slate-400">В этом ящике пока пусто</p>
) : (
<div className="grid gap-2">
{box.objects.map((object) => (
<ObjectCard key={object.id} object={object} />
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Link, useParams } from 'react-router'
import { useObject } from '../../hooks/useObjects'
import { PhotoGallery } from '../../components/PhotoGallery'
export function ObjectDetailPage() {
const { id } = useParams<{ id: string }>()
const { data: object, isLoading, isError } = useObject(id)
if (isLoading) {
return <p className="p-4 text-center text-slate-500">Загрузка...</p>
}
if (isError || !object) {
return <p className="p-4 text-center text-slate-500">Объект не найден</p>
}
return (
<div className="mx-auto max-w-lg space-y-4 p-4">
{object.box && (
<Link to={`/boxes/${object.box.id}`} className="text-sm text-slate-500 hover:underline dark:text-slate-400">
{object.box.name}
</Link>
)}
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{object.name}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">Инв. номер: {object.inventory_number}</p>
</div>
{object.description && (
<p className="whitespace-pre-wrap text-slate-700 dark:text-slate-300">{object.description}</p>
)}
<PhotoGallery photos={object.photos} />
</div>
)
}

47
frontend/src/router.tsx Normal file
View File

@@ -0,0 +1,47 @@
import { Navigate, Route, Routes } from 'react-router'
import { useAuth } from './auth/AuthContext'
import { RequireAuth } from './auth/RequireAuth'
import { AdminLayout } from './components/Layout'
import { LoginPage } from './pages/LoginPage'
import { BoxBrowsePage } from './pages/admin/BoxBrowsePage'
import { BoxFormPage } from './pages/admin/BoxFormPage'
import { ObjectFormPage } from './pages/admin/ObjectFormPage'
import { ObjectListPage } from './pages/admin/ObjectListPage'
import { BoxDetailPage } from './pages/public/BoxDetailPage'
import { ObjectDetailPage } from './pages/public/ObjectDetailPage'
function Home() {
const { isAuthenticated } = useAuth()
return <Navigate to={isAuthenticated ? '/admin/objects' : '/login'} replace />
}
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/objects/:id" element={<ObjectDetailPage />} />
<Route path="/boxes/:id" element={<BoxDetailPage />} />
<Route
path="/admin"
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route path="objects" element={<ObjectListPage />} />
<Route path="objects/new" element={<ObjectFormPage />} />
<Route path="objects/:id/edit" element={<ObjectFormPage />} />
<Route path="boxes" element={<BoxBrowsePage />} />
<Route path="boxes/new" element={<BoxFormPage />} />
<Route path="boxes/:id" element={<BoxBrowsePage />} />
<Route path="boxes/:id/edit" element={<BoxFormPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
host: true,
port: 5173,
// Dev-only: this server never leaves the docker-compose network / localhost.
allowedHosts: true,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
},
},
},
})