start
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user