feat: add location crud
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s

This commit is contained in:
魏风
2026-03-12 19:47:41 +08:00
parent f0a0667b84
commit ef93d4e5c2
19 changed files with 1278 additions and 8 deletions

View File

@@ -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 ###

View File

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

View 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")

View File

@@ -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

View File

@@ -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

View 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"

View File

@@ -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()

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

View File

@@ -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: {

View File

@@ -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

View File

@@ -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;
}; };

View 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

View 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

View 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

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

View 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>
),
},
]

View File

@@ -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" },
] ]

View File

@@ -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,
} }

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