feat: add location crud
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
"""add location table
|
||||||
|
|
||||||
|
Revision ID: 0b117fab4208
|
||||||
|
Revises: fe56fa70289e
|
||||||
|
Create Date: 2026-03-12 11:07:24.856505
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel.sql.sqltypes
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0b117fab4208'
|
||||||
|
down_revision = 'fe56fa70289e'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('location',
|
||||||
|
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
||||||
|
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('location')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.routes import items, login, private, users, utils
|
from app.api.routes import items, locations, login, private, users, utils
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -8,7 +8,9 @@ api_router.include_router(login.router)
|
|||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(utils.router)
|
api_router.include_router(utils.router)
|
||||||
api_router.include_router(items.router)
|
api_router.include_router(items.router)
|
||||||
|
api_router.include_router(locations.router)
|
||||||
|
|
||||||
|
|
||||||
if settings.ENVIRONMENT == "local":
|
if settings.ENVIRONMENT == "local":
|
||||||
api_router.include_router(private.router)
|
api_router.include_router(private.router)
|
||||||
|
|
||||||
|
|||||||
119
backend/app/api/routes/locations.py
Normal file
119
backend/app/api/routes/locations.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlmodel import col, func, select
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.models import (
|
||||||
|
Location,
|
||||||
|
LocationCreate,
|
||||||
|
LocationPublic,
|
||||||
|
LocationsPublic,
|
||||||
|
LocationUpdate,
|
||||||
|
Message,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=LocationsPublic)
|
||||||
|
def read_locations(
|
||||||
|
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Retrieve locations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if current_user.is_superuser:
|
||||||
|
count_statement = select(func.count()).select_from(Location)
|
||||||
|
count = session.exec(count_statement).one()
|
||||||
|
statement = (
|
||||||
|
select(Location).order_by(col(Location.created_at).desc()).offset(skip).limit(limit)
|
||||||
|
)
|
||||||
|
locations = session.exec(statement).all()
|
||||||
|
else:
|
||||||
|
count_statement = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Location)
|
||||||
|
.where(Location.owner_id == current_user.id)
|
||||||
|
)
|
||||||
|
count = session.exec(count_statement).one()
|
||||||
|
statement = (
|
||||||
|
select(Location)
|
||||||
|
.where(Location.owner_id == current_user.id)
|
||||||
|
.order_by(col(Location.created_at).desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
locations = session.exec(statement).all()
|
||||||
|
|
||||||
|
return LocationsPublic(data=locations, count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}", response_model=LocationPublic)
|
||||||
|
def read_location(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||||
|
"""
|
||||||
|
Get location by ID.
|
||||||
|
"""
|
||||||
|
location = session.get(Location, id)
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=LocationPublic)
|
||||||
|
def create_location(
|
||||||
|
*, session: SessionDep, current_user: CurrentUser, location_in: LocationCreate
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Create new location.
|
||||||
|
"""
|
||||||
|
location = Location.model_validate(location_in, update={"owner_id": current_user.id})
|
||||||
|
session.add(location)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(location)
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{id}", response_model=LocationPublic)
|
||||||
|
def update_location(
|
||||||
|
*,
|
||||||
|
session: SessionDep,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
id: uuid.UUID,
|
||||||
|
location_in: LocationUpdate,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Update a location.
|
||||||
|
"""
|
||||||
|
location = session.get(Location, id)
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
|
update_dict = location_in.model_dump(exclude_unset=True)
|
||||||
|
location.sqlmodel_update(update_dict)
|
||||||
|
session.add(location)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(location)
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}")
|
||||||
|
def delete_location(
|
||||||
|
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||||
|
) -> Message:
|
||||||
|
"""
|
||||||
|
Delete a location.
|
||||||
|
"""
|
||||||
|
location = session.get(Location, id)
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||||
|
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||||
|
session.delete(location)
|
||||||
|
session.commit()
|
||||||
|
return Message(message="Location deleted successfully")
|
||||||
@@ -4,7 +4,15 @@ from typing import Any
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.security import get_password_hash, verify_password
|
from app.core.security import get_password_hash, verify_password
|
||||||
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
|
from app.models import (
|
||||||
|
Item,
|
||||||
|
ItemCreate,
|
||||||
|
Location,
|
||||||
|
LocationCreate,
|
||||||
|
User,
|
||||||
|
UserCreate,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
||||||
@@ -66,3 +74,11 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_item)
|
session.refresh(db_item)
|
||||||
return db_item
|
return db_item
|
||||||
|
|
||||||
|
|
||||||
|
def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
|
||||||
|
db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
|
||||||
|
session.add(db_location)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(db_location)
|
||||||
|
return db_location
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class User(UserBase, table=True):
|
|||||||
sa_type=DateTime(timezone=True), # type: ignore
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
)
|
)
|
||||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||||
|
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||||
|
|
||||||
|
|
||||||
# Properties to return via API, id is always required
|
# Properties to return via API, id is always required
|
||||||
@@ -108,6 +109,47 @@ class ItemsPublic(SQLModel):
|
|||||||
count: int
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
# Shared properties
|
||||||
|
class LocationBase(SQLModel):
|
||||||
|
title: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# Properties to receive on location creation
|
||||||
|
class LocationCreate(LocationBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Properties to receive on location update
|
||||||
|
class LocationUpdate(LocationBase):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# Database model, database table inferred from class name
|
||||||
|
class Location(LocationBase, table=True):
|
||||||
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
|
created_at: datetime | None = Field(
|
||||||
|
default_factory=get_datetime_utc,
|
||||||
|
sa_type=DateTime(timezone=True), # type: ignore
|
||||||
|
)
|
||||||
|
owner_id: uuid.UUID = Field(
|
||||||
|
foreign_key="user.id", nullable=False, ondelete="CASCADE"
|
||||||
|
)
|
||||||
|
owner: User | None = Relationship(back_populates="locations")
|
||||||
|
|
||||||
|
|
||||||
|
# Properties to return via API, id is always required
|
||||||
|
class LocationPublic(LocationBase):
|
||||||
|
id: uuid.UUID
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LocationsPublic(SQLModel):
|
||||||
|
data: list[LocationPublic]
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
# Generic message
|
# Generic message
|
||||||
class Message(SQLModel):
|
class Message(SQLModel):
|
||||||
message: str
|
message: str
|
||||||
|
|||||||
164
backend/tests/api/routes/test_locations.py
Normal file
164
backend/tests/api/routes/test_locations.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from tests.utils.location import create_random_location
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_location(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
data = {"title": "Test Location", "description": "A test location"}
|
||||||
|
response = client.post(
|
||||||
|
f"{settings.API_V1_STR}/locations/",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["title"] == data["title"]
|
||||||
|
assert content["description"] == data["description"]
|
||||||
|
assert "id" in content
|
||||||
|
assert "owner_id" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_location(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["title"] == location.title
|
||||||
|
assert content["description"] == location.description
|
||||||
|
assert content["id"] == str(location.id)
|
||||||
|
assert content["owner_id"] == str(location.owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_location_not_found(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Location not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_location_not_enough_permissions(
|
||||||
|
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=normal_user_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Not enough permissions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_locations(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
create_random_location(db)
|
||||||
|
create_random_location(db)
|
||||||
|
response = client.get(
|
||||||
|
f"{settings.API_V1_STR}/locations/",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert len(content["data"]) >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_location(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
data = {"title": "Updated title", "description": "Updated description"}
|
||||||
|
response = client.put(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["title"] == data["title"]
|
||||||
|
assert content["description"] == data["description"]
|
||||||
|
assert content["id"] == str(location.id)
|
||||||
|
assert content["owner_id"] == str(location.owner_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_location_not_found(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
data = {"title": "Updated title", "description": "Updated description"}
|
||||||
|
response = client.put(
|
||||||
|
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Location not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_location_not_enough_permissions(
|
||||||
|
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
data = {"title": "Updated title", "description": "Updated description"}
|
||||||
|
response = client.put(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=normal_user_token_headers,
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Not enough permissions"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_location(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.json()
|
||||||
|
assert content["message"] == "Location deleted successfully"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_location_not_found(
|
||||||
|
client: TestClient, superuser_token_headers: dict[str, str]
|
||||||
|
) -> None:
|
||||||
|
response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||||
|
headers=superuser_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Location not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_location_not_enough_permissions(
|
||||||
|
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||||
|
) -> None:
|
||||||
|
location = create_random_location(db)
|
||||||
|
response = client.delete(
|
||||||
|
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||||
|
headers=normal_user_token_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
content = response.json()
|
||||||
|
assert content["detail"] == "Not enough permissions"
|
||||||
@@ -7,7 +7,7 @@ from sqlmodel import Session, delete
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.db import engine, init_db
|
from app.core.db import engine, init_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.models import Item, User
|
from app.models import Item, Location, User
|
||||||
from tests.utils.user import authentication_token_from_email
|
from tests.utils.user import authentication_token_from_email
|
||||||
from tests.utils.utils import get_superuser_token_headers
|
from tests.utils.utils import get_superuser_token_headers
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ def db() -> Generator[Session, None, None]:
|
|||||||
yield session
|
yield session
|
||||||
statement = delete(Item)
|
statement = delete(Item)
|
||||||
session.execute(statement)
|
session.execute(statement)
|
||||||
|
statement = delete(Location)
|
||||||
|
session.execute(statement)
|
||||||
statement = delete(User)
|
statement = delete(User)
|
||||||
session.execute(statement)
|
session.execute(statement)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
16
backend/tests/utils/location.py
Normal file
16
backend/tests/utils/location.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app import crud
|
||||||
|
from app.models import Location, LocationCreate
|
||||||
|
from tests.utils.user import create_random_user
|
||||||
|
from tests.utils.utils import random_lower_string
|
||||||
|
|
||||||
|
|
||||||
|
def create_random_location(db: Session) -> Location:
|
||||||
|
user = create_random_user(db)
|
||||||
|
owner_id = user.id
|
||||||
|
assert owner_id is not None
|
||||||
|
title = random_lower_string()
|
||||||
|
description = random_lower_string()
|
||||||
|
location_in = LocationCreate(title=title, description=description)
|
||||||
|
return crud.create_location(session=db, location_in=location_in, owner_id=owner_id)
|
||||||
@@ -196,6 +196,131 @@ export const ItemsPublicSchema = {
|
|||||||
title: 'ItemsPublic'
|
title: 'ItemsPublic'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const LocationCreateSchema = {
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
minLength: 1,
|
||||||
|
title: 'Title'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Description'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['title'],
|
||||||
|
title: 'LocationCreate'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LocationPublicSchema = {
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
minLength: 1,
|
||||||
|
title: 'Title'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Description'
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
title: 'Id'
|
||||||
|
},
|
||||||
|
owner_id: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
title: 'Owner Id'
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
format: 'date-time'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Created At'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['title', 'id', 'owner_id'],
|
||||||
|
title: 'LocationPublic'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LocationUpdateSchema = {
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255,
|
||||||
|
minLength: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Title'
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 255
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'null'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
title: 'Description'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
title: 'LocationUpdate'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LocationsPublicSchema = {
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
items: {
|
||||||
|
'$ref': '#/components/schemas/LocationPublic'
|
||||||
|
},
|
||||||
|
type: 'array',
|
||||||
|
title: 'Data'
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: 'integer',
|
||||||
|
title: 'Count'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
required: ['data', 'count'],
|
||||||
|
title: 'LocationsPublic'
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const MessageSchema = {
|
export const MessageSchema = {
|
||||||
properties: {
|
properties: {
|
||||||
message: {
|
message: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
import { request as __request } from './core/request';
|
||||||
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LocationsReadLocationsData, LocationsReadLocationsResponse, LocationsCreateLocationData, LocationsCreateLocationResponse, LocationsReadLocationData, LocationsReadLocationResponse, LocationsUpdateLocationData, LocationsUpdateLocationResponse, LocationsDeleteLocationData, LocationsDeleteLocationResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||||
|
|
||||||
export class ItemsService {
|
export class ItemsService {
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +116,117 @@ export class ItemsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LocationsService {
|
||||||
|
/**
|
||||||
|
* Read Locations
|
||||||
|
* Retrieve locations.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.skip
|
||||||
|
* @param data.limit
|
||||||
|
* @returns LocationsPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static readLocations(data: LocationsReadLocationsData = {}): CancelablePromise<LocationsReadLocationsResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/locations/',
|
||||||
|
query: {
|
||||||
|
skip: data.skip,
|
||||||
|
limit: data.limit
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Location
|
||||||
|
* Create new location.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.requestBody
|
||||||
|
* @returns LocationPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static createLocation(data: LocationsCreateLocationData): CancelablePromise<LocationsCreateLocationResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/locations/',
|
||||||
|
body: data.requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Location
|
||||||
|
* Get location by ID.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.id
|
||||||
|
* @returns LocationPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static readLocation(data: LocationsReadLocationData): CancelablePromise<LocationsReadLocationResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/locations/{id}',
|
||||||
|
path: {
|
||||||
|
id: data.id
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Location
|
||||||
|
* Update a location.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.id
|
||||||
|
* @param data.requestBody
|
||||||
|
* @returns LocationPublic Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static updateLocation(data: LocationsUpdateLocationData): CancelablePromise<LocationsUpdateLocationResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/locations/{id}',
|
||||||
|
path: {
|
||||||
|
id: data.id
|
||||||
|
},
|
||||||
|
body: data.requestBody,
|
||||||
|
mediaType: 'application/json',
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Location
|
||||||
|
* Delete a location.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.id
|
||||||
|
* @returns Message Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static deleteLocation(data: LocationsDeleteLocationData): CancelablePromise<LocationsDeleteLocationResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/locations/{id}',
|
||||||
|
path: {
|
||||||
|
id: data.id
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class LoginService {
|
export class LoginService {
|
||||||
/**
|
/**
|
||||||
* Login Access Token
|
* Login Access Token
|
||||||
|
|||||||
@@ -36,6 +36,29 @@ export type ItemUpdate = {
|
|||||||
description?: (string | null);
|
description?: (string | null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LocationCreate = {
|
||||||
|
title: string;
|
||||||
|
description?: (string | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationPublic = {
|
||||||
|
title: string;
|
||||||
|
description?: (string | null);
|
||||||
|
id: string;
|
||||||
|
owner_id: string;
|
||||||
|
created_at?: (string | null);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsPublic = {
|
||||||
|
data: Array<LocationPublic>;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationUpdate = {
|
||||||
|
title?: (string | null);
|
||||||
|
description?: (string | null);
|
||||||
|
};
|
||||||
|
|
||||||
export type Message = {
|
export type Message = {
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
@@ -145,6 +168,38 @@ export type ItemsDeleteItemData = {
|
|||||||
|
|
||||||
export type ItemsDeleteItemResponse = (Message);
|
export type ItemsDeleteItemResponse = (Message);
|
||||||
|
|
||||||
|
export type LocationsReadLocationsData = {
|
||||||
|
limit?: number;
|
||||||
|
skip?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsReadLocationsResponse = (LocationsPublic);
|
||||||
|
|
||||||
|
export type LocationsCreateLocationData = {
|
||||||
|
requestBody: LocationCreate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsCreateLocationResponse = (LocationPublic);
|
||||||
|
|
||||||
|
export type LocationsReadLocationData = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsReadLocationResponse = (LocationPublic);
|
||||||
|
|
||||||
|
export type LocationsUpdateLocationData = {
|
||||||
|
id: string;
|
||||||
|
requestBody: LocationUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsUpdateLocationResponse = (LocationPublic);
|
||||||
|
|
||||||
|
export type LocationsDeleteLocationData = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LocationsDeleteLocationResponse = (Message);
|
||||||
|
|
||||||
export type LoginLoginAccessTokenData = {
|
export type LoginLoginAccessTokenData = {
|
||||||
formData: Body_login_login_access_token;
|
formData: Body_login_login_access_token;
|
||||||
};
|
};
|
||||||
|
|||||||
144
frontend/src/components/Locations/AddLocation.tsx
Normal file
144
frontend/src/components/Locations/AddLocation.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Plus } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { type LocationCreate, LocationsService } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: "Title is required" }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
const AddLocation = () => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onBlur",
|
||||||
|
criteriaMode: "all",
|
||||||
|
defaultValues: {
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: LocationCreate) =>
|
||||||
|
LocationsService.createLocation({ requestBody: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessToast("Location created successfully")
|
||||||
|
form.reset()
|
||||||
|
setIsOpen(false)
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["locations"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
mutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="my-4">
|
||||||
|
<Plus className="mr-2" />
|
||||||
|
Add Location
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Location</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Fill in the details to add a new location.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Title"
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Description" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={mutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddLocation
|
||||||
94
frontend/src/components/Locations/DeleteLocation.tsx
Normal file
94
frontend/src/components/Locations/DeleteLocation.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Trash2 } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
|
||||||
|
import { LocationsService } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||||
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
|
interface DeleteLocationProps {
|
||||||
|
id: string
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteLocation = ({ id, onSuccess }: DeleteLocationProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
const { handleSubmit } = useForm()
|
||||||
|
|
||||||
|
const deleteLocation = async (id: string) => {
|
||||||
|
await LocationsService.deleteLocation({ id: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: deleteLocation,
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessToast("The location was deleted successfully")
|
||||||
|
setIsOpen(false)
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
mutation.mutate(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
Delete Location
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Location</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This location will be permanently deleted. Are you sure? You will not
|
||||||
|
be able to undo this action.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={mutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton
|
||||||
|
variant="destructive"
|
||||||
|
type="submit"
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteLocation
|
||||||
145
frontend/src/components/Locations/EditLocation.tsx
Normal file
145
frontend/src/components/Locations/EditLocation.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { Pencil } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { type LocationPublic, LocationsService } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { LoadingButton } from "@/components/ui/loading-button"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(1, { message: "Title is required" }),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
interface EditLocationProps {
|
||||||
|
location: LocationPublic
|
||||||
|
onSuccess: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditLocation = ({ location, onSuccess }: EditLocationProps) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
mode: "onBlur",
|
||||||
|
criteriaMode: "all",
|
||||||
|
defaultValues: {
|
||||||
|
title: location.title,
|
||||||
|
description: location.description ?? undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: FormData) =>
|
||||||
|
LocationsService.updateLocation({ id: location.id, requestBody: data }),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccessToast("Location updated successfully")
|
||||||
|
setIsOpen(false)
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: handleError.bind(showErrorToast),
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["locations"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: FormData) => {
|
||||||
|
mutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
Edit Location
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Location</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the location details below.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="title"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Title <span className="text-destructive">*</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Title" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Description" type="text" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={mutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||||
|
Save
|
||||||
|
</LoadingButton>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditLocation
|
||||||
34
frontend/src/components/Locations/LocationActionsMenu.tsx
Normal file
34
frontend/src/components/Locations/LocationActionsMenu.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { EllipsisVertical } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import type { LocationPublic } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import DeleteLocation from "../Locations/DeleteLocation"
|
||||||
|
import EditLocation from "../Locations/EditLocation"
|
||||||
|
|
||||||
|
interface LocationActionsMenuProps {
|
||||||
|
location: LocationPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LocationActionsMenu = ({ location }: LocationActionsMenuProps) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<EditLocation location={location} onSuccess={() => setOpen(false)} />
|
||||||
|
<DeleteLocation id={location.id} onSuccess={() => setOpen(false)} />
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
frontend/src/components/Locations/columns.tsx
Normal file
73
frontend/src/components/Locations/columns.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Check, Copy } from "lucide-react"
|
||||||
|
|
||||||
|
import type { LocationPublic } from "@/client"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { LocationActionsMenu } from "./LocationActionsMenu"
|
||||||
|
|
||||||
|
function CopyId({ id }: { id: string }) {
|
||||||
|
const [copiedText, copy] = useCopyToClipboard()
|
||||||
|
const isCopied = copiedText === id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 group">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{id}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => copy(id)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="size-3 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Copy ID</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<LocationPublic>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => <CopyId id={row.original.id} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "title",
|
||||||
|
header: "Title",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-medium">{row.original.title}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "description",
|
||||||
|
header: "Description",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const description = row.original.description
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"max-w-xs truncate block text-muted-foreground",
|
||||||
|
!description && "italic",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{description || "No description"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
header: () => <span className="sr-only">Actions</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<LocationActionsMenu location={row.original} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Briefcase, Home, Users } from "lucide-react"
|
import { Briefcase, Home, MapPin, Users } from "lucide-react"
|
||||||
|
|
||||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||||
import { Logo } from "@/components/Common/Logo"
|
import { Logo } from "@/components/Common/Logo"
|
||||||
@@ -14,6 +14,7 @@ import { User } from "./User"
|
|||||||
|
|
||||||
const baseItems: Item[] = [
|
const baseItems: Item[] = [
|
||||||
{ icon: Home, title: "Dashboard", path: "/" },
|
{ icon: Home, title: "Dashboard", path: "/" },
|
||||||
|
{ icon: MapPin, title: "Locations", path: "/locations" },
|
||||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
|||||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||||
|
import { Route as LayoutLocationsRouteImport } from './routes/_layout/locations'
|
||||||
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
import { Route as LayoutItemsRouteImport } from './routes/_layout/items'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutLocationsRoute = LayoutLocationsRouteImport.update({
|
||||||
|
id: '/locations',
|
||||||
|
path: '/locations',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
const LayoutItemsRoute = LayoutItemsRouteImport.update({
|
||||||
id: '/items',
|
id: '/items',
|
||||||
path: '/items',
|
path: '/items',
|
||||||
@@ -65,14 +71,15 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
|||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof LayoutIndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/recover-password': typeof RecoverPasswordRoute
|
'/recover-password': typeof RecoverPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/items': typeof LayoutItemsRoute
|
'/items': typeof LayoutItemsRoute
|
||||||
|
'/locations': typeof LayoutLocationsRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/': typeof LayoutIndexRoute
|
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -81,6 +88,7 @@ export interface FileRoutesByTo {
|
|||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/admin': typeof LayoutAdminRoute
|
'/admin': typeof LayoutAdminRoute
|
||||||
'/items': typeof LayoutItemsRoute
|
'/items': typeof LayoutItemsRoute
|
||||||
|
'/locations': typeof LayoutLocationsRoute
|
||||||
'/settings': typeof LayoutSettingsRoute
|
'/settings': typeof LayoutSettingsRoute
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -93,20 +101,22 @@ export interface FileRoutesById {
|
|||||||
'/signup': typeof SignupRoute
|
'/signup': typeof SignupRoute
|
||||||
'/_layout/admin': typeof LayoutAdminRoute
|
'/_layout/admin': typeof LayoutAdminRoute
|
||||||
'/_layout/items': typeof LayoutItemsRoute
|
'/_layout/items': typeof LayoutItemsRoute
|
||||||
|
'/_layout/locations': typeof LayoutLocationsRoute
|
||||||
'/_layout/settings': typeof LayoutSettingsRoute
|
'/_layout/settings': typeof LayoutSettingsRoute
|
||||||
'/_layout/': typeof LayoutIndexRoute
|
'/_layout/': typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/recover-password'
|
| '/recover-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/items'
|
| '/items'
|
||||||
|
| '/locations'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/'
|
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -115,6 +125,7 @@ export interface FileRouteTypes {
|
|||||||
| '/signup'
|
| '/signup'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/items'
|
| '/items'
|
||||||
|
| '/locations'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/'
|
| '/'
|
||||||
id:
|
id:
|
||||||
@@ -126,6 +137,7 @@ export interface FileRouteTypes {
|
|||||||
| '/signup'
|
| '/signup'
|
||||||
| '/_layout/admin'
|
| '/_layout/admin'
|
||||||
| '/_layout/items'
|
| '/_layout/items'
|
||||||
|
| '/_layout/locations'
|
||||||
| '/_layout/settings'
|
| '/_layout/settings'
|
||||||
| '/_layout/'
|
| '/_layout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
@@ -171,7 +183,7 @@ declare module '@tanstack/react-router' {
|
|||||||
'/_layout': {
|
'/_layout': {
|
||||||
id: '/_layout'
|
id: '/_layout'
|
||||||
path: ''
|
path: ''
|
||||||
fullPath: ''
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof LayoutRouteImport
|
preLoaderRoute: typeof LayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
@@ -189,6 +201,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutSettingsRouteImport
|
preLoaderRoute: typeof LayoutSettingsRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/locations': {
|
||||||
|
id: '/_layout/locations'
|
||||||
|
path: '/locations'
|
||||||
|
fullPath: '/locations'
|
||||||
|
preLoaderRoute: typeof LayoutLocationsRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_layout/items': {
|
'/_layout/items': {
|
||||||
id: '/_layout/items'
|
id: '/_layout/items'
|
||||||
path: '/items'
|
path: '/items'
|
||||||
@@ -209,6 +228,7 @@ declare module '@tanstack/react-router' {
|
|||||||
interface LayoutRouteChildren {
|
interface LayoutRouteChildren {
|
||||||
LayoutAdminRoute: typeof LayoutAdminRoute
|
LayoutAdminRoute: typeof LayoutAdminRoute
|
||||||
LayoutItemsRoute: typeof LayoutItemsRoute
|
LayoutItemsRoute: typeof LayoutItemsRoute
|
||||||
|
LayoutLocationsRoute: typeof LayoutLocationsRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
}
|
}
|
||||||
@@ -216,6 +236,7 @@ interface LayoutRouteChildren {
|
|||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
LayoutAdminRoute: LayoutAdminRoute,
|
LayoutAdminRoute: LayoutAdminRoute,
|
||||||
LayoutItemsRoute: LayoutItemsRoute,
|
LayoutItemsRoute: LayoutItemsRoute,
|
||||||
|
LayoutLocationsRoute: LayoutLocationsRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
69
frontend/src/routes/_layout/locations.tsx
Normal file
69
frontend/src/routes/_layout/locations.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useSuspenseQuery } from "@tanstack/react-query"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { LocationsService } from "@/client"
|
||||||
|
import { DataTable } from "@/components/Common/DataTable"
|
||||||
|
import AddLocation from "@/components/Locations/AddLocation"
|
||||||
|
import { columns } from "@/components/Locations/columns"
|
||||||
|
import PendingItems from "@/components/Pending/PendingItems"
|
||||||
|
|
||||||
|
function getLocationsQueryOptions() {
|
||||||
|
return {
|
||||||
|
queryFn: () => LocationsService.readLocations({ skip: 0, limit: 100 }),
|
||||||
|
queryKey: ["locations"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/locations")({
|
||||||
|
component: Locations,
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: "Locations - FastAPI Template",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function LocationsTableContent() {
|
||||||
|
const { data: locations } = useSuspenseQuery(getLocationsQueryOptions())
|
||||||
|
|
||||||
|
if (locations.data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center text-center py-12">
|
||||||
|
<div className="rounded-full bg-muted p-4 mb-4">
|
||||||
|
<Search className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">You don't have any locations yet</h3>
|
||||||
|
<p className="text-muted-foreground">Add a new location to get started</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DataTable columns={columns} data={locations.data} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationsTable() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PendingItems />}>
|
||||||
|
<LocationsTableContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Locations() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Locations</h1>
|
||||||
|
<p className="text-muted-foreground">Create and manage your locations</p>
|
||||||
|
</div>
|
||||||
|
<AddLocation />
|
||||||
|
</div>
|
||||||
|
<LocationsTable />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user