feat: Add batch operations, dashboard redesign, hierarchical locations
Some checks failed
CI / style (push) Has been cancelled
CI / build-client (push) Has been cancelled
CI / build-amd64 (push) Has been cancelled
CI / build-tester (push) Has been cancelled
CI / test (cockroachdb) (push) Has been cancelled
CI / test (mariadb) (push) Has been cancelled
CI / test (postgres) (push) Has been cancelled
CI / test (sqlite) (push) Has been cancelled
CI / build-arm64 (push) Has been cancelled
CI / build-armv7 (push) Has been cancelled
CI / publish-images (push) Has been cancelled
CI / publish-release (push) Has been cancelled

## Batch Operations (#13)
- Add POST /spool/bulk/archive, /bulk/delete, /bulk/update endpoints
- Add row selection with checkboxes to spool list
- Add floating BatchActionBar with archive/unarchive/delete/move actions
- Add confirmation modals for all batch operations

## Dashboard Redesign (#12)
- Install recharts for pie charts
- Add AlertCards (low stock, pending jobs, needs weighing)
- Add MaterialDistributionChart (pie chart by material)
- Add QuickStats row (total spools, filaments, vendors, weight)
- Refactor home page layout with new components

## Hierarchical Locations (#9)
- Add Location model with self-referential parent relationship
- Add migration for location table and spool.location_id FK
- Add location database operations (CRUD, tree, full path)
- Add location API endpoints (list, tree, CRUD)
- Add LocationTreeSelect component with nested tree display
- Add LocationCreateModal for inline location creation
- Keep legacy location string field for backward compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 21:40:01 -06:00
parent 27757bb949
commit 02da984b6e
18 changed files with 1683 additions and 90 deletions

164
spoolman/api/v1/location.py Normal file
View File

@@ -0,0 +1,164 @@
"""Location related endpoints."""
from typing import Annotated, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from spoolman.api.v1.models import Message
from spoolman.database import location
from spoolman.database.database import get_db_session
from spoolman.database import models
router = APIRouter(
prefix="/location",
tags=["location"],
)
class LocationResponse(BaseModel):
"""Location response model."""
id: int
name: str
parent_id: Optional[int] = None
location_type: Optional[str] = None
description: Optional[str] = None
full_path: Optional[str] = None # Only populated for single-item requests
spool_count: Optional[int] = None # Only populated when requested
@staticmethod
def from_db(item: models.Location, full_path: Optional[str] = None) -> "LocationResponse":
return LocationResponse(
id=item.id,
name=item.name,
parent_id=item.parent_id,
location_type=item.location_type,
description=item.description,
full_path=full_path,
)
class LocationTreeNode(BaseModel):
"""Location tree node for nested structure."""
id: int
name: str
location_type: Optional[str] = None
description: Optional[str] = None
children: list["LocationTreeNode"] = []
class LocationParameters(BaseModel):
"""Parameters for creating a location."""
name: str = Field(max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
class LocationUpdateParameters(BaseModel):
"""Parameters for updating a location."""
name: Optional[str] = Field(None, max_length=64, description="Location name.")
parent_id: Optional[int] = Field(None, description="Parent location ID.")
location_type: Optional[str] = Field(None, max_length=32, description="Type: room, shelf, bin, etc.")
description: Optional[str] = Field(None, max_length=256, description="Optional description.")
@router.get(
"",
name="Find locations",
description="Get all locations as a flat list.",
response_model=list[LocationResponse],
)
async def find_locations(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[LocationResponse]:
db_items = await location.find_all(db=db)
return [LocationResponse.from_db(item) for item in db_items]
@router.get(
"/tree",
name="Get location tree",
description="Get all locations as a nested tree structure.",
response_model=list[LocationTreeNode],
)
async def get_location_tree(
db: Annotated[AsyncSession, Depends(get_db_session)],
) -> list[dict]:
return await location.get_tree(db=db)
@router.get(
"/{location_id}",
name="Get location",
description="Get a specific location by ID.",
response_model=LocationResponse,
responses={404: {"model": Message}},
)
async def get_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> LocationResponse:
db_item = await location.get_by_id(db=db, location_id=location_id)
full_path = await location.get_full_path(db=db, location_id=location_id)
spool_count = await location.get_spool_count(db=db, location_id=location_id)
response = LocationResponse.from_db(db_item, full_path=full_path)
response.spool_count = spool_count
return response
@router.post(
"",
name="Create location",
description="Create a new location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def create_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
body: LocationParameters,
) -> LocationResponse:
db_item = await location.create(
db=db,
name=body.name,
parent_id=body.parent_id,
location_type=body.location_type,
description=body.description,
)
return LocationResponse.from_db(db_item)
@router.patch(
"/{location_id}",
name="Update location",
description="Update a location.",
response_model=LocationResponse,
responses={400: {"model": Message}, 404: {"model": Message}},
)
async def update_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
body: LocationUpdateParameters,
) -> LocationResponse:
data = body.model_dump(exclude_unset=True)
db_item = await location.update(db=db, location_id=location_id, data=data)
return LocationResponse.from_db(db_item)
@router.delete(
"/{location_id}",
name="Delete location",
description="Delete a location. Spools using this location will have their location_id set to null.",
responses={404: {"model": Message}},
)
async def delete_location(
db: Annotated[AsyncSession, Depends(get_db_session)],
location_id: int,
) -> Message:
await location.delete(db=db, location_id=location_id)
return Message(message="Success!")