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

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>
)
}