start
This commit is contained in:
2
frontend/.dockerignore
Normal file
2
frontend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
8
frontend/.oxlintrc.json
Normal 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
18
frontend/Dockerfile
Normal 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
32
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
18
frontend/nginx.conf
Normal 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
1780
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
70
frontend/src/api/client.ts
Normal file
70
frontend/src/api/client.ts
Normal 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
61
frontend/src/api/types.ts
Normal 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
|
||||
}
|
||||
38
frontend/src/auth/AuthContext.tsx
Normal file
38
frontend/src/auth/AuthContext.tsx
Normal 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
|
||||
}
|
||||
13
frontend/src/auth/RequireAuth.tsx
Normal file
13
frontend/src/auth/RequireAuth.tsx
Normal 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}</>
|
||||
}
|
||||
14
frontend/src/components/BoxCard.tsx
Normal file
14
frontend/src/components/BoxCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/Breadcrumbs.tsx
Normal file
26
frontend/src/components/Breadcrumbs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
frontend/src/components/Button.tsx
Normal file
23
frontend/src/components/Button.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
35
frontend/src/components/Layout.tsx
Normal file
35
frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
frontend/src/components/ObjectCard.tsx
Normal file
14
frontend/src/components/ObjectCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
frontend/src/components/PhotoGallery.tsx
Normal file
39
frontend/src/components/PhotoGallery.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
53
frontend/src/components/PhotoUploader.tsx
Normal file
53
frontend/src/components/PhotoUploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
frontend/src/components/QrCodeCard.tsx
Normal file
31
frontend/src/components/QrCodeCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
frontend/src/hooks/useBoxes.ts
Normal file
46
frontend/src/hooks/useBoxes.ts
Normal 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'] }),
|
||||
})
|
||||
}
|
||||
69
frontend/src/hooks/useObjects.ts
Normal file
69
frontend/src/hooks/useObjects.ts
Normal 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
18
frontend/src/index.css
Normal 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
23
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
71
frontend/src/pages/LoginPage.tsx
Normal file
71
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
frontend/src/pages/admin/BoxBrowsePage.tsx
Normal file
126
frontend/src/pages/admin/BoxBrowsePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
126
frontend/src/pages/admin/BoxFormPage.tsx
Normal file
126
frontend/src/pages/admin/BoxFormPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
frontend/src/pages/admin/ObjectFormPage.tsx
Normal file
233
frontend/src/pages/admin/ObjectFormPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
frontend/src/pages/admin/ObjectListPage.tsx
Normal file
58
frontend/src/pages/admin/ObjectListPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
frontend/src/pages/public/BoxDetailPage.tsx
Normal file
49
frontend/src/pages/public/BoxDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/pages/public/ObjectDetailPage.tsx
Normal file
33
frontend/src/pages/public/ObjectDetailPage.tsx
Normal 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
47
frontend/src/router.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user