diff --git a/backend/app/alembic/versions/0b117fab4208_add_location_table.py b/backend/app/alembic/versions/0b117fab4208_add_location_table.py new file mode 100644 index 0000000..acb741d --- /dev/null +++ b/backend/app/alembic/versions/0b117fab4208_add_location_table.py @@ -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 ### diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8..fcca221 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,6 @@ 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 api_router = APIRouter() @@ -8,7 +8,9 @@ api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router(locations.router) if settings.ENVIRONMENT == "local": api_router.include_router(private.router) + diff --git a/backend/app/api/routes/locations.py b/backend/app/api/routes/locations.py new file mode 100644 index 0000000..52e3c8d --- /dev/null +++ b/backend/app/api/routes/locations.py @@ -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") diff --git a/backend/app/crud.py b/backend/app/crud.py index a8ceba6..d60e252 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -4,7 +4,15 @@ from typing import Any from sqlmodel import Session, select 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: @@ -66,3 +74,11 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(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 diff --git a/backend/app/models.py b/backend/app/models.py index b5132e0..9c036c1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -54,6 +54,7 @@ class User(UserBase, table=True): sa_type=DateTime(timezone=True), # type: ignore ) 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 @@ -108,6 +109,47 @@ class ItemsPublic(SQLModel): 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 class Message(SQLModel): message: str diff --git a/backend/tests/api/routes/test_locations.py b/backend/tests/api/routes/test_locations.py new file mode 100644 index 0000000..2ebba46 --- /dev/null +++ b/backend/tests/api/routes/test_locations.py @@ -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" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8ddab7b..8073e4d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,7 @@ from sqlmodel import Session, delete from app.core.config import settings from app.core.db import engine, init_db 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.utils import get_superuser_token_headers @@ -19,6 +19,8 @@ def db() -> Generator[Session, None, None]: yield session statement = delete(Item) session.execute(statement) + statement = delete(Location) + session.execute(statement) statement = delete(User) session.execute(statement) session.commit() diff --git a/backend/tests/utils/location.py b/backend/tests/utils/location.py new file mode 100644 index 0000000..595f94e --- /dev/null +++ b/backend/tests/utils/location.py @@ -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) diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index fb66c1f..6d42385 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -196,6 +196,131 @@ export const ItemsPublicSchema = { title: 'ItemsPublic' } 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 = { properties: { message: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f..df0af02 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; 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 { /** @@ -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 { + 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 { + 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 { + 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 { + 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 { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/locations/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class LoginService { /** * Login Access Token diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 91b5ba3..6a97a75 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -36,6 +36,29 @@ export type ItemUpdate = { 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; + count: number; +}; + +export type LocationUpdate = { + title?: (string | null); + description?: (string | null); +}; + export type Message = { message: string; }; @@ -145,6 +168,38 @@ export type ItemsDeleteItemData = { 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 = { formData: Body_login_login_access_token; }; diff --git a/frontend/src/components/Locations/AddLocation.tsx b/frontend/src/components/Locations/AddLocation.tsx new file mode 100644 index 0000000..b30ecf8 --- /dev/null +++ b/frontend/src/components/Locations/AddLocation.tsx @@ -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 + +const AddLocation = () => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const form = useForm({ + 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 ( + + + + + + + Add Location + + Fill in the details to add a new location. + + +
+ +
+ ( + + + Title * + + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default AddLocation diff --git a/frontend/src/components/Locations/DeleteLocation.tsx b/frontend/src/components/Locations/DeleteLocation.tsx new file mode 100644 index 0000000..7d6ef8d --- /dev/null +++ b/frontend/src/components/Locations/DeleteLocation.tsx @@ -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 ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Delete Location + + +
+ + Delete Location + + This location will be permanently deleted. Are you sure? You will not + be able to undo this action. + + + + + + + + + Delete + + +
+
+
+ ) +} + +export default DeleteLocation diff --git a/frontend/src/components/Locations/EditLocation.tsx b/frontend/src/components/Locations/EditLocation.tsx new file mode 100644 index 0000000..d9657dd --- /dev/null +++ b/frontend/src/components/Locations/EditLocation.tsx @@ -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 + +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({ + 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 ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Edit Location + + +
+ + + Edit Location + + Update the location details below. + + +
+ ( + + + Title * + + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default EditLocation diff --git a/frontend/src/components/Locations/LocationActionsMenu.tsx b/frontend/src/components/Locations/LocationActionsMenu.tsx new file mode 100644 index 0000000..3cf77eb --- /dev/null +++ b/frontend/src/components/Locations/LocationActionsMenu.tsx @@ -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 ( + + + + + + setOpen(false)} /> + setOpen(false)} /> + + + ) +} diff --git a/frontend/src/components/Locations/columns.tsx b/frontend/src/components/Locations/columns.tsx new file mode 100644 index 0000000..ce1bca6 --- /dev/null +++ b/frontend/src/components/Locations/columns.tsx @@ -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 ( +
+ {id} + +
+ ) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => , + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => ( + {row.original.title} + ), + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => { + const description = row.original.description + return ( + + {description || "No description"} + + ) + }, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( +
+ +
+ ), + }, +] diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 8502bcb..a175600 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -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 { Logo } from "@/components/Common/Logo" @@ -14,6 +14,7 @@ import { User } from "./User" const baseItems: Item[] = [ { icon: Home, title: "Dashboard", path: "/" }, + { icon: MapPin, title: "Locations", path: "/locations" }, { icon: Briefcase, title: "Items", path: "/items" }, ] diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 8849130..9b5145d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as LayoutRouteImport } from './routes/_layout' import { Route as LayoutIndexRouteImport } from './routes/_layout/index' 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 LayoutAdminRouteImport } from './routes/_layout/admin' @@ -53,6 +54,11 @@ const LayoutSettingsRoute = LayoutSettingsRouteImport.update({ path: '/settings', getParentRoute: () => LayoutRoute, } as any) +const LayoutLocationsRoute = LayoutLocationsRouteImport.update({ + id: '/locations', + path: '/locations', + getParentRoute: () => LayoutRoute, +} as any) const LayoutItemsRoute = LayoutItemsRouteImport.update({ id: '/items', path: '/items', @@ -65,14 +71,15 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({ } as any) export interface FileRoutesByFullPath { + '/': typeof LayoutIndexRoute '/login': typeof LoginRoute '/recover-password': typeof RecoverPasswordRoute '/reset-password': typeof ResetPasswordRoute '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute + '/locations': typeof LayoutLocationsRoute '/settings': typeof LayoutSettingsRoute - '/': typeof LayoutIndexRoute } export interface FileRoutesByTo { '/login': typeof LoginRoute @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/signup': typeof SignupRoute '/admin': typeof LayoutAdminRoute '/items': typeof LayoutItemsRoute + '/locations': typeof LayoutLocationsRoute '/settings': typeof LayoutSettingsRoute '/': typeof LayoutIndexRoute } @@ -93,20 +101,22 @@ export interface FileRoutesById { '/signup': typeof SignupRoute '/_layout/admin': typeof LayoutAdminRoute '/_layout/items': typeof LayoutItemsRoute + '/_layout/locations': typeof LayoutLocationsRoute '/_layout/settings': typeof LayoutSettingsRoute '/_layout/': typeof LayoutIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: + | '/' | '/login' | '/recover-password' | '/reset-password' | '/signup' | '/admin' | '/items' + | '/locations' | '/settings' - | '/' fileRoutesByTo: FileRoutesByTo to: | '/login' @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/signup' | '/admin' | '/items' + | '/locations' | '/settings' | '/' id: @@ -126,6 +137,7 @@ export interface FileRouteTypes { | '/signup' | '/_layout/admin' | '/_layout/items' + | '/_layout/locations' | '/_layout/settings' | '/_layout/' fileRoutesById: FileRoutesById @@ -171,7 +183,7 @@ declare module '@tanstack/react-router' { '/_layout': { id: '/_layout' path: '' - fullPath: '' + fullPath: '/' preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } @@ -189,6 +201,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSettingsRouteImport parentRoute: typeof LayoutRoute } + '/_layout/locations': { + id: '/_layout/locations' + path: '/locations' + fullPath: '/locations' + preLoaderRoute: typeof LayoutLocationsRouteImport + parentRoute: typeof LayoutRoute + } '/_layout/items': { id: '/_layout/items' path: '/items' @@ -209,6 +228,7 @@ declare module '@tanstack/react-router' { interface LayoutRouteChildren { LayoutAdminRoute: typeof LayoutAdminRoute LayoutItemsRoute: typeof LayoutItemsRoute + LayoutLocationsRoute: typeof LayoutLocationsRoute LayoutSettingsRoute: typeof LayoutSettingsRoute LayoutIndexRoute: typeof LayoutIndexRoute } @@ -216,6 +236,7 @@ interface LayoutRouteChildren { const LayoutRouteChildren: LayoutRouteChildren = { LayoutAdminRoute: LayoutAdminRoute, LayoutItemsRoute: LayoutItemsRoute, + LayoutLocationsRoute: LayoutLocationsRoute, LayoutSettingsRoute: LayoutSettingsRoute, LayoutIndexRoute: LayoutIndexRoute, } diff --git a/frontend/src/routes/_layout/locations.tsx b/frontend/src/routes/_layout/locations.tsx new file mode 100644 index 0000000..f4e7c4e --- /dev/null +++ b/frontend/src/routes/_layout/locations.tsx @@ -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 ( +
+
+ +
+

You don't have any locations yet

+

Add a new location to get started

+
+ ) + } + + return +} + +function LocationsTable() { + return ( + }> + + + ) +} + +function Locations() { + return ( +
+
+
+

Locations

+

Create and manage your locations

+
+ +
+ +
+ ) +}