Add inventory audit mode with camera QR scanning
Lets an employee walk the premises, scan each object's QR to confirm it's present and record where it was found (defaulting to the last picked location), and track found/not-found progress for the walk. Scanning a box's QR shows its contents read-only without affecting the found/not-found counts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""add last_seen_at to objects
|
||||
|
||||
Revision ID: c3bc00a214e0
|
||||
Revises: cdcadb640107
|
||||
Create Date: 2026-07-22 08:05:44.372353
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3bc00a214e0'
|
||||
down_revision: Union[str, None] = 'cdcadb640107'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('objects', sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('objects', 'last_seen_at')
|
||||
# ### end Alembic commands ###
|
||||
@@ -27,6 +27,7 @@ class InventoryObject(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
box: Mapped["Box | None"] = relationship(back_populates="objects")
|
||||
photos: Mapped[list["ObjectPhoto"]] = relationship(
|
||||
|
||||
@@ -4,7 +4,7 @@ from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.core.dependencies import CurrentUser, DbSession, get_minio_client
|
||||
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate
|
||||
from app.schemas.object import ObjectCreate, ObjectRead, ObjectScan, ObjectUpdate
|
||||
from app.services import objects as objects_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
|
||||
@@ -53,6 +53,16 @@ async def update_object(
|
||||
return objects_service.build_object_read(obj)
|
||||
|
||||
|
||||
@router.post("/{object_id}/scan", response_model=ObjectRead)
|
||||
async def scan_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID, data: ObjectScan) -> ObjectRead:
|
||||
"""Mark an object as found during an inventory audit walk, recording where it was seen."""
|
||||
obj = await objects_service.get_object(db, object_id)
|
||||
if obj is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
|
||||
obj = await objects_service.mark_object_found(db, obj, data.box_id)
|
||||
return objects_service.build_object_read(obj)
|
||||
|
||||
|
||||
@router.delete("/{object_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_object(db: DbSession, _: CurrentUser, object_id: uuid.UUID) -> None:
|
||||
obj = await objects_service.get_object(db, object_id)
|
||||
|
||||
@@ -35,6 +35,10 @@ class ObjectUpdate(BaseModel):
|
||||
box_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class ObjectScan(BaseModel):
|
||||
box_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class ObjectRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -46,5 +50,6 @@ class ObjectRead(BaseModel):
|
||||
box: BoxRef | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_seen_at: datetime | None
|
||||
qr_code_url: str
|
||||
photos: list[PhotoRead]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -20,6 +21,7 @@ def build_object_read(obj: InventoryObject) -> ObjectRead:
|
||||
box=BoxRef.model_validate(obj.box) if obj.box else None,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
last_seen_at=obj.last_seen_at,
|
||||
qr_code_url=object_public_url(obj.id),
|
||||
photos=[build_photo_read(p) for p in obj.photos],
|
||||
)
|
||||
@@ -62,6 +64,13 @@ async def update_object(db: AsyncSession, obj: InventoryObject, data: ObjectUpda
|
||||
return await get_object(db, obj.id)
|
||||
|
||||
|
||||
async def mark_object_found(db: AsyncSession, obj: InventoryObject, box_id: uuid.UUID | None) -> InventoryObject:
|
||||
obj.box_id = box_id
|
||||
obj.last_seen_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return await get_object(db, obj.id)
|
||||
|
||||
|
||||
async def delete_object(db: AsyncSession, minio_client, obj: InventoryObject) -> None:
|
||||
for photo in list(obj.photos):
|
||||
await delete_photo(db, minio_client, photo)
|
||||
|
||||
@@ -47,6 +47,28 @@ async def test_duplicate_inventory_number_conflicts(client: AsyncClient, auth_he
|
||||
await client.delete(f"/api/v1/objects/{first.json()['id']}", headers=auth_headers)
|
||||
|
||||
|
||||
async def test_scan_marks_object_found_and_moves_it(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||
create_res = await client.post(
|
||||
"/api/v1/objects", headers=auth_headers, json={"inventory_number": inventory_number, "name": "Scan test"}
|
||||
)
|
||||
object_id = create_res.json()["id"]
|
||||
assert create_res.json()["last_seen_at"] is None
|
||||
|
||||
box_res = await client.post("/api/v1/boxes", headers=auth_headers, json={"name": "Audit room"})
|
||||
box_id = box_res.json()["id"]
|
||||
|
||||
scan_res = await client.post(
|
||||
f"/api/v1/objects/{object_id}/scan", headers=auth_headers, json={"box_id": box_id}
|
||||
)
|
||||
assert scan_res.status_code == 200
|
||||
assert scan_res.json()["box_id"] == box_id
|
||||
assert scan_res.json()["last_seen_at"] is not None
|
||||
|
||||
await client.delete(f"/api/v1/objects/{object_id}", headers=auth_headers)
|
||||
await client.delete(f"/api/v1/boxes/{box_id}", headers=auth_headers)
|
||||
|
||||
|
||||
async def test_object_qrcode(client: AsyncClient, auth_headers: dict[str, str]) -> None:
|
||||
inventory_number = f"TEST-{uuid.uuid4().hex[:8]}"
|
||||
create_res = await client.post(
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.82.0",
|
||||
@@ -1150,6 +1151,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tanstack/react-query": "^5.101.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.82.0",
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface InventoryObject {
|
||||
box: BoxRef | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
last_seen_at: string | null
|
||||
qr_code_url: string
|
||||
photos: Photo[]
|
||||
}
|
||||
@@ -41,6 +42,10 @@ export interface ObjectInput {
|
||||
box_id?: string | null
|
||||
}
|
||||
|
||||
export interface ObjectScanInput {
|
||||
box_id?: string | null
|
||||
}
|
||||
|
||||
export interface Box {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
@@ -22,6 +22,9 @@ export function AdminLayout() {
|
||||
<NavLink to="/admin/boxes" className={linkClass}>
|
||||
Ящики
|
||||
</NavLink>
|
||||
<NavLink to="/admin/audit" className={linkClass}>
|
||||
Инвентаризация
|
||||
</NavLink>
|
||||
</nav>
|
||||
<Button variant="secondary" onClick={logout}>
|
||||
Выйти
|
||||
|
||||
91
frontend/src/components/QrScanner.tsx
Normal file
91
frontend/src/components/QrScanner.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import jsQR from 'jsqr'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface QrScannerProps {
|
||||
/** When false, the camera keeps running but decoded codes are ignored (e.g. while a confirmation card is open). */
|
||||
active: boolean
|
||||
onDecode: (text: string) => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
export function QrScanner({ active, onDecode, onError }: QrScannerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const activeRef = useRef(active)
|
||||
const onDecodeRef = useRef(onDecode)
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active
|
||||
}, [active])
|
||||
useEffect(() => {
|
||||
onDecodeRef.current = onDecode
|
||||
}, [onDecode])
|
||||
|
||||
useEffect(() => {
|
||||
let stream: MediaStream | null = null
|
||||
let rafId = 0
|
||||
let cancelled = false
|
||||
|
||||
function tick() {
|
||||
if (cancelled) return
|
||||
const video = videoRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (activeRef.current && video && canvas && video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
canvas.width = video.videoWidth
|
||||
canvas.height = video.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height)
|
||||
if (code?.data) onDecodeRef.current(code.data)
|
||||
}
|
||||
}
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
onError('Камера недоступна: браузер её не поддерживает, либо сайт открыт не по HTTPS.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' } },
|
||||
audio: false,
|
||||
})
|
||||
} catch {
|
||||
onError('Нет доступа к камере. Разрешите доступ в настройках браузера и обновите страницу.')
|
||||
return
|
||||
}
|
||||
if (cancelled) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
return
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream
|
||||
await videoRef.current.play()
|
||||
}
|
||||
tick()
|
||||
}
|
||||
|
||||
start()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(rafId)
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
}
|
||||
// Camera setup runs once; `active`/`onDecode` are read through refs to stay fresh.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square overflow-hidden rounded-lg bg-black">
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video ref={videoRef} muted playsInline className="h-full w-full object-cover" />
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<div className="pointer-events-none absolute inset-10 rounded-lg border-2 border-white/70" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
frontend/src/hooks/useAuditSession.ts
Normal file
37
frontend/src/hooks/useAuditSession.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
const SESSION_START_KEY = 'inventory_audit_session_start'
|
||||
const LAST_BOX_KEY = 'inventory_audit_last_box_id'
|
||||
|
||||
function readSessionStart(): number {
|
||||
const raw = sessionStorage.getItem(SESSION_START_KEY)
|
||||
if (raw) return Number(raw)
|
||||
const now = Date.now()
|
||||
sessionStorage.setItem(SESSION_START_KEY, String(now))
|
||||
return now
|
||||
}
|
||||
|
||||
/** Tracks the current audit walk: when it started (for the found/not-found split) and the
|
||||
* last room/box picked, so the next scanned object can default to the same location. */
|
||||
export function useAuditSession() {
|
||||
const [sessionStart, setSessionStart] = useState<number>(readSessionStart)
|
||||
const [lastBoxId, setLastBoxIdState] = useState<string | null>(() =>
|
||||
sessionStorage.getItem(LAST_BOX_KEY),
|
||||
)
|
||||
|
||||
const restart = useCallback(() => {
|
||||
const now = Date.now()
|
||||
sessionStorage.setItem(SESSION_START_KEY, String(now))
|
||||
sessionStorage.removeItem(LAST_BOX_KEY)
|
||||
setSessionStart(now)
|
||||
setLastBoxIdState(null)
|
||||
}, [])
|
||||
|
||||
const setLastBoxId = useCallback((boxId: string | null) => {
|
||||
if (boxId) sessionStorage.setItem(LAST_BOX_KEY, boxId)
|
||||
else sessionStorage.removeItem(LAST_BOX_KEY)
|
||||
setLastBoxIdState(boxId)
|
||||
}, [])
|
||||
|
||||
return { sessionStart, restart, lastBoxId, setLastBoxId }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import type { InventoryObject, ObjectInput, Photo } from '../api/types'
|
||||
import type { InventoryObject, ObjectInput, ObjectScanInput, Photo } from '../api/types'
|
||||
|
||||
export function useObjectsList(search: string) {
|
||||
return useQuery({
|
||||
@@ -48,6 +48,18 @@ export function useDeleteObject() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useScanObject() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, boxId }: { id: string; boxId: string | null }) =>
|
||||
api.post<InventoryObject>(`/objects/${id}/scan`, { box_id: boxId } satisfies ObjectScanInput),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['objects'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['boxes'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUploadPhoto(objectId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
23
frontend/src/lib/qrUrl.ts
Normal file
23
frontend/src/lib/qrUrl.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const UUID_RE = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
|
||||
const OBJECT_PATH_RE = new RegExp(`^/objects/(${UUID_RE})$`, 'i')
|
||||
const BOX_PATH_RE = new RegExp(`^/boxes/(${UUID_RE})$`, 'i')
|
||||
|
||||
export type ScannedTarget = { type: 'object' | 'box'; id: string }
|
||||
|
||||
/** Parses a decoded QR string into an object/box reference if it points at this app, else null. */
|
||||
export function parseScannedTarget(text: string): ScannedTarget | null {
|
||||
let pathname: string
|
||||
try {
|
||||
pathname = new URL(text).pathname
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const objectMatch = pathname.match(OBJECT_PATH_RE)
|
||||
if (objectMatch) return { type: 'object', id: objectMatch[1] }
|
||||
|
||||
const boxMatch = pathname.match(BOX_PATH_RE)
|
||||
if (boxMatch) return { type: 'box', id: boxMatch[1] }
|
||||
|
||||
return null
|
||||
}
|
||||
246
frontend/src/pages/admin/AuditPage.tsx
Normal file
246
frontend/src/pages/admin/AuditPage.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router'
|
||||
import { Button } from '../../components/Button'
|
||||
import { PhotoGallery } from '../../components/PhotoGallery'
|
||||
import { QrScanner } from '../../components/QrScanner'
|
||||
import { useAuditSession } from '../../hooks/useAuditSession'
|
||||
import { useBox, useBoxesList } from '../../hooks/useBoxes'
|
||||
import { useObject, useObjectsList, useScanObject } from '../../hooks/useObjects'
|
||||
import { parseScannedTarget, type ScannedTarget } from '../../lib/qrUrl'
|
||||
|
||||
const RESCAN_COOLDOWN_MS = 4000
|
||||
|
||||
export function AuditPage() {
|
||||
const { sessionStart, restart, lastBoxId, setLastBoxId } = useAuditSession()
|
||||
const { data: objects } = useObjectsList('')
|
||||
const { data: boxes } = useBoxesList('')
|
||||
const scanObject = useScanObject()
|
||||
|
||||
const [overlay, setOverlay] = useState<ScannedTarget | null>(null)
|
||||
const [selectedBoxId, setSelectedBoxId] = useState('')
|
||||
const [cameraError, setCameraError] = useState<string | null>(null)
|
||||
const cooldownRef = useRef<{ key: string; at: number } | null>(null)
|
||||
|
||||
const { data: scannedObject } = useObject(overlay?.type === 'object' ? overlay.id : undefined)
|
||||
const { data: viewedBox } = useBox(overlay?.type === 'box' ? overlay.id : undefined)
|
||||
|
||||
const handleDecode = useCallback(
|
||||
(text: string) => {
|
||||
const target = parseScannedTarget(text)
|
||||
if (!target) return
|
||||
|
||||
const key = `${target.type}:${target.id}`
|
||||
const cooldown = cooldownRef.current
|
||||
if (cooldown && cooldown.key === key && Date.now() - cooldown.at < RESCAN_COOLDOWN_MS) return
|
||||
|
||||
if (target.type === 'object') setSelectedBoxId(lastBoxId ?? '')
|
||||
setOverlay(target)
|
||||
},
|
||||
[lastBoxId],
|
||||
)
|
||||
|
||||
function closeOverlay() {
|
||||
if (overlay) cooldownRef.current = { key: `${overlay.type}:${overlay.id}`, at: Date.now() }
|
||||
setOverlay(null)
|
||||
}
|
||||
|
||||
async function confirmFound() {
|
||||
if (!overlay || overlay.type !== 'object') return
|
||||
const boxId = selectedBoxId || null
|
||||
await scanObject.mutateAsync({ id: overlay.id, boxId })
|
||||
setLastBoxId(boxId)
|
||||
closeOverlay()
|
||||
}
|
||||
|
||||
const foundIds = new Set(
|
||||
(objects ?? [])
|
||||
.filter((o) => o.last_seen_at && new Date(o.last_seen_at).getTime() >= sessionStart)
|
||||
.map((o) => o.id),
|
||||
)
|
||||
const foundObjects = (objects ?? []).filter((o) => foundIds.has(o.id))
|
||||
const notFoundObjects = (objects ?? []).filter((o) => !foundIds.has(o.id))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Инвентаризация</h1>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (confirm('Начать инвентаризацию заново? Списки найденного обнулятся.')) restart()
|
||||
}}
|
||||
>
|
||||
Начать заново
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
{cameraError ? (
|
||||
<div className="flex aspect-square flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
<p>{cameraError}</p>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()}>
|
||||
Обновить страницу
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<QrScanner active={overlay === null} onDecode={handleDecode} onError={setCameraError} />
|
||||
)}
|
||||
|
||||
{overlay?.type === 'object' && (
|
||||
<div className="absolute inset-0 flex items-end bg-black/50 p-2 sm:items-center sm:justify-center">
|
||||
<div className="w-full max-w-sm space-y-3 rounded-lg bg-white p-4 dark:bg-slate-900">
|
||||
{!scannedObject ? (
|
||||
<p className="text-sm text-slate-500">Загрузка объекта...</p>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{scannedObject.name}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{scannedObject.inventory_number}
|
||||
</p>
|
||||
</div>
|
||||
{scannedObject.description && (
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{scannedObject.description}</p>
|
||||
)}
|
||||
{scannedObject.photos.length > 0 && <PhotoGallery photos={scannedObject.photos} />}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Где нашли?</label>
|
||||
<select
|
||||
value={selectedBoxId}
|
||||
onChange={(e) => setSelectedBoxId(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-950"
|
||||
>
|
||||
<option value="">— без ящика —</option>
|
||||
{boxes?.map((box) => (
|
||||
<option key={box.id} value={box.id}>
|
||||
{box.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={confirmFound} disabled={scanObject.isPending}>
|
||||
{scanObject.isPending ? 'Сохранение...' : 'Найден здесь'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={closeOverlay} disabled={scanObject.isPending}>
|
||||
Пропустить
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{overlay?.type === 'box' && (
|
||||
<div className="absolute inset-0 flex items-end bg-black/50 p-2 sm:items-center sm:justify-center">
|
||||
<div className="max-h-full w-full max-w-sm space-y-3 overflow-y-auto rounded-lg bg-white p-4 dark:bg-slate-900">
|
||||
{!viewedBox ? (
|
||||
<p className="text-sm text-slate-500">Загрузка ящика...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span aria-hidden>📦</span>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{viewedBox.name}</p>
|
||||
</div>
|
||||
{viewedBox.description && (
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">{viewedBox.description}</p>
|
||||
)}
|
||||
|
||||
{viewedBox.children.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">Вложенные ящики</p>
|
||||
{viewedBox.children.map((child) => (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => setOverlay({ type: 'box', id: child.id })}
|
||||
className="flex w-full items-center gap-2 rounded-md py-1 text-left text-sm hover:underline"
|
||||
>
|
||||
📦 {child.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">
|
||||
Объекты ({viewedBox.objects.length})
|
||||
</p>
|
||||
{viewedBox.objects.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Пусто</p>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{viewedBox.objects.map((o) => (
|
||||
<div key={o.id} className="flex items-center justify-between gap-2 py-1.5 text-sm">
|
||||
<span>
|
||||
{o.name}{' '}
|
||||
<span className="text-slate-500 dark:text-slate-400">· {o.inventory_number}</span>
|
||||
</span>
|
||||
{foundIds.has(o.id) && <span className="shrink-0 text-emerald-600">✓ найден</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="secondary" onClick={closeOverlay}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
Найдено ({foundObjects.length})
|
||||
</h2>
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{foundObjects.map((o) => (
|
||||
<Link
|
||||
key={o.id}
|
||||
to={`/admin/objects/${o.id}/edit`}
|
||||
className="flex items-center justify-between gap-2 py-2 text-sm hover:underline"
|
||||
>
|
||||
<span>
|
||||
{o.name} <span className="text-slate-500 dark:text-slate-400">· {o.inventory_number}</span>
|
||||
</span>
|
||||
{o.box && <span className="shrink-0 text-slate-500 dark:text-slate-400">{o.box.name}</span>}
|
||||
</Link>
|
||||
))}
|
||||
{foundObjects.length === 0 && (
|
||||
<p className="py-2 text-sm text-slate-500 dark:text-slate-400">Пока ничего не найдено</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-medium text-slate-500 dark:text-slate-400">
|
||||
Не найдено ({notFoundObjects.length})
|
||||
</h2>
|
||||
<div className="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
{notFoundObjects.map((o) => (
|
||||
<Link
|
||||
key={o.id}
|
||||
to={`/admin/objects/${o.id}/edit`}
|
||||
className="flex items-center justify-between gap-2 py-2 text-sm hover:underline"
|
||||
>
|
||||
<span>
|
||||
{o.name} <span className="text-slate-500 dark:text-slate-400">· {o.inventory_number}</span>
|
||||
</span>
|
||||
{o.box && <span className="shrink-0 text-slate-500 dark:text-slate-400">{o.box.name}</span>}
|
||||
</Link>
|
||||
))}
|
||||
{notFoundObjects.length === 0 && (
|
||||
<p className="py-2 text-sm text-slate-500 dark:text-slate-400">Все объекты найдены</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useAuth } from './auth/AuthContext'
|
||||
import { RequireAuth } from './auth/RequireAuth'
|
||||
import { AdminLayout } from './components/Layout'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { AuditPage } from './pages/admin/AuditPage'
|
||||
import { BoxBrowsePage } from './pages/admin/BoxBrowsePage'
|
||||
import { BoxFormPage } from './pages/admin/BoxFormPage'
|
||||
import { ObjectFormPage } from './pages/admin/ObjectFormPage'
|
||||
@@ -39,6 +40,7 @@ export function AppRoutes() {
|
||||
<Route path="boxes/new" element={<BoxFormPage />} />
|
||||
<Route path="boxes/:id" element={<BoxBrowsePage />} />
|
||||
<Route path="boxes/:id/edit" element={<BoxFormPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
Reference in New Issue
Block a user