[FWA-5] Home Create and List

This commit is contained in:
Sam Liu 2024-07-03 08:57:50 +00:00
parent 1c205c69ac
commit b938f296c1
33 changed files with 414 additions and 93 deletions

View File

@ -1,8 +1,8 @@
"""create house and area table """create house and area table
Revision ID: 7ef4aef5fcda Revision ID: 0fbca538155d
Revises: 4b90a6ac504b Revises: 4b90a6ac504b
Create Date: 2024-06-25 09:20:59.915212 Create Date: 2024-06-30 05:38:20.062935
""" """
from typing import Sequence, Union from typing import Sequence, Union
@ -13,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '7ef4aef5fcda' revision: str = '0fbca538155d'
down_revision: Union[str, None] = '4b90a6ac504b' down_revision: Union[str, None] = '4b90a6ac504b'
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -28,11 +28,10 @@ def upgrade() -> None:
sa.Column('address', sa.String(), nullable=False), sa.Column('address', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_houses_address'), 'houses', ['address'], unique=False)
op.create_index(op.f('ix_houses_created_at'), 'houses', ['created_at'], unique=False) op.create_index(op.f('ix_houses_created_at'), 'houses', ['created_at'], unique=False)
op.create_index(op.f('ix_houses_icon'), 'houses', ['icon'], unique=False)
op.create_index(op.f('ix_houses_id'), 'houses', ['id'], unique=False) op.create_index(op.f('ix_houses_id'), 'houses', ['id'], unique=False)
op.create_index(op.f('ix_houses_name'), 'houses', ['name'], unique=False) op.create_index(op.f('ix_houses_name'), 'houses', ['name'], unique=False)
op.create_table('areas', op.create_table('areas',
@ -42,12 +41,11 @@ def upgrade() -> None:
sa.Column('desc', sa.String(), nullable=False), sa.Column('desc', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['house_id'], ['houses.id'], ), sa.Column('deleted_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['house_id'], ['houses.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')
) )
op.create_index(op.f('ix_areas_created_at'), 'areas', ['created_at'], unique=False) op.create_index(op.f('ix_areas_created_at'), 'areas', ['created_at'], unique=False)
op.create_index(op.f('ix_areas_desc'), 'areas', ['desc'], unique=False)
op.create_index(op.f('ix_areas_house_id'), 'areas', ['house_id'], unique=False)
op.create_index(op.f('ix_areas_id'), 'areas', ['id'], unique=False) op.create_index(op.f('ix_areas_id'), 'areas', ['id'], unique=False)
op.create_index(op.f('ix_areas_name'), 'areas', ['name'], unique=False) op.create_index(op.f('ix_areas_name'), 'areas', ['name'], unique=False)
# ### end Alembic commands ### # ### end Alembic commands ###
@ -57,14 +55,10 @@ def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_areas_name'), table_name='areas') op.drop_index(op.f('ix_areas_name'), table_name='areas')
op.drop_index(op.f('ix_areas_id'), table_name='areas') op.drop_index(op.f('ix_areas_id'), table_name='areas')
op.drop_index(op.f('ix_areas_house_id'), table_name='areas')
op.drop_index(op.f('ix_areas_desc'), table_name='areas')
op.drop_index(op.f('ix_areas_created_at'), table_name='areas') op.drop_index(op.f('ix_areas_created_at'), table_name='areas')
op.drop_table('areas') op.drop_table('areas')
op.drop_index(op.f('ix_houses_name'), table_name='houses') op.drop_index(op.f('ix_houses_name'), table_name='houses')
op.drop_index(op.f('ix_houses_id'), table_name='houses') op.drop_index(op.f('ix_houses_id'), table_name='houses')
op.drop_index(op.f('ix_houses_icon'), table_name='houses')
op.drop_index(op.f('ix_houses_created_at'), table_name='houses') op.drop_index(op.f('ix_houses_created_at'), table_name='houses')
op.drop_index(op.f('ix_houses_address'), table_name='houses')
op.drop_table('houses') op.drop_table('houses')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -27,7 +27,7 @@ def upgrade() -> None:
sa.Column('password', sa.String(), nullable=False), sa.Column('password', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True), sa.Column('name', sa.String(), nullable=True),
sa.Column('is_admin', sa.Boolean(), nullable=True), sa.Column('is_admin', sa.Boolean(), nullable=True),
sa.Column('is_lock', sa.Boolean(), nullable=True), sa.Column('is_lock', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id')

View File

@ -1,6 +1,7 @@
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException from fastapi import FastAPI, Request, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
@ -70,6 +71,11 @@ async def unicorn_exception_handler(request: Request, exc: HTTPException):
content={"status": exc.status_code, "data": exc.detail}, content={"status": exc.status_code, "data": exc.detail},
) )
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
print(exc)
return JSONResponse(status_code=422, content={"status": 422, "data": str(exc)})
def api_routers(): def api_routers():
app.include_router(router) app.include_router(router)

View File

@ -30,7 +30,7 @@ async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail)) -> bool:
user = user_service.get_by_id(user_id) user = user_service.get_by_id(user_id)
if not user: if not user:
raise credentials_exception raise credentials_exception
if user.is_lock is True: if user.is_lock is not None:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK) raise HTTPException(status_code=status.HTTP_423_LOCKED, detail=MessageCode.ACCOUNT_LOCK)
except Exception: except Exception:
return credentials_exception return credentials_exception

View File

@ -1,6 +1,9 @@
class MessageCode(): class MessageCode():
CREATE_USER_SUCCESS: str = 'CREATE_USER_SUCCESS'
CREATED_USER: str = 'CREATED_USER' CREATED_USER: str = 'CREATED_USER'
WRONG_INPUT: str = 'LOGIN_WRONG' WRONG_INPUT: str = 'LOGIN_WRONG'
ACCOUNT_LOCK: str = 'USER_LOCK' ACCOUNT_LOCK: str = 'USER_LOCK'
REFRESH_TOKEN_EXPIRED: str = 'REFRESH_TOKEN_EXPIRED' REFRESH_TOKEN_EXPIRED: str = 'REFRESH_TOKEN_EXPIRED'
CREATE_HOUSE_FAIL: str = 'CREATE_HOUSE_FAIL'
CREATE_HOUSE_SUCCESS: str = 'CREATE_HOUSE_SUCCESS'

View File

@ -18,3 +18,6 @@ class SqlAlchemyBase(Model):
@classmethod @classmethod
def normalize(cls, val: str) -> str: def normalize(cls, val: str) -> str:
return unidecode(val).lower().strip() return unidecode(val).lower().strip()
class DeleteMixin:
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

View File

@ -4,30 +4,30 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.db.models.guid import GUID from backend.db.models.guid import GUID
from .._model_base import SqlAlchemyBase from .._model_base import SqlAlchemyBase, DeleteMixin
class Houses(SqlAlchemyBase): class Houses(SqlAlchemyBase, DeleteMixin):
__tablename__ = 'houses' __tablename__ = 'houses'
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate, index=True) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate, index=True)
icon: Mapped[str | None] = mapped_column(String, index=True, nullable=False) icon: Mapped[str | None] = mapped_column(String, nullable=False)
name: Mapped[str | None] = mapped_column(String, index=True, nullable=False) name: Mapped[str | None] = mapped_column(String, index=True, nullable=False)
address: Mapped[str | None] = mapped_column(String, index=True, nullable=False) address: Mapped[str | None] = mapped_column(String, nullable=False)
areas: Mapped[List["Areas"]] = relationship() areas: Mapped[List["Areas"]] = relationship("Areas", back_populates="house", cascade="all, delete", passive_deletes=True)
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}" return f"{self.__class__.__name__}, name: {self.name}"
class Areas(SqlAlchemyBase): class Areas(SqlAlchemyBase, DeleteMixin):
__tablename__ = 'areas' __tablename__ = 'areas'
id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate, index=True) id: Mapped[GUID] = mapped_column(GUID, primary_key=True, default=GUID.generate, index=True)
house_id: Mapped[GUID] = mapped_column(GUID, ForeignKey('houses.id'), index=True, nullable=False) house_id: Mapped[GUID] = mapped_column(GUID, ForeignKey('houses.id', ondelete='CASCADE'), nullable=False)
name: Mapped[str | None] = mapped_column(String, index=True, nullable=False) name: Mapped[str | None] = mapped_column(String, index=True, nullable=False)
desc: Mapped[str | None] = mapped_column(String, index=True, nullable=False) desc: Mapped[str | None] = mapped_column(String, nullable=False)
user: Mapped['Houses'] = relationship(back_populates="areas") house: Mapped['Houses'] = relationship(back_populates="areas")
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}" return f"{self.__class__.__name__}, name: {self.name}"

View File

@ -1,4 +1,6 @@
from sqlalchemy import Boolean, String from datetime import datetime
from sqlalchemy import Boolean, String, DateTime
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from backend.db.models.guid import GUID from backend.db.models.guid import GUID
@ -13,7 +15,7 @@ class Users(SqlAlchemyBase):
password: Mapped[str | None] = mapped_column(String, index=True, nullable=False) password: Mapped[str | None] = mapped_column(String, index=True, nullable=False)
name: Mapped[str | None] = mapped_column(String, index=True, nullable=True) name: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
is_admin: Mapped[bool | None] = mapped_column(Boolean, default=False) is_admin: Mapped[bool | None] = mapped_column(Boolean, default=False)
is_lock: Mapped[bool | None] = mapped_column(Boolean, default=False) is_lock: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
def __repr__(self): def __repr__(self):
return f"{self.__class__.__name__}, name: {self.name}, username: {self.username}" return f"{self.__class__.__name__}, name: {self.name}, username: {self.username}"

View File

@ -1,4 +1,4 @@
from backend.db.models.houses import Houses from backend.db.models.houses import Houses, Areas
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.schemas.house import HouseCreate from backend.schemas.house import HouseCreate
@ -6,16 +6,26 @@ from backend.schemas.house import HouseCreate
class RepositoryHouses: class RepositoryHouses:
def __init__(self): def __init__(self):
self.houses = Houses() self.houses = Houses()
self.areas = Areas()
def get_all(self, skip: int = 0, limit: int = 100): def get_all(self, skip: int = 0, limit: int = 100):
return self.houses.query.offset(skip).limit(limit).all() return self.houses.query.filter_by(deleted_at=None).offset(skip).limit(limit).all()
def get_all_areas(self, house_id: str, skip: int = 0, limit: int = 100):
return self.areas.query.filter_by(deleted_at=None, house_id=house_id).offset(skip).limit(limit).all()
def get_count_all(self, skip: int = 0, limit: int = 100):
return self.houses.query.filter_by(deleted_at=None).offset(skip).limit(limit).count()
def get_by_id(self, house_id: str): def get_by_id(self, house_id: str):
return self.houses.query.filter_by(id=house_id).one() return self.houses.query.filter_by(id=house_id).one()
def create(self, db: Session, house: HouseCreate): def create(self, db: Session, house: HouseCreate):
try: try:
db_house = Houses(**house.dict()) areas = getattr(house, "areas")
db_house = Houses(**house.dict(exclude={"areas"}))
for area in areas:
db_house.areas.append(Areas(**area.dict()))
db.add(db_house) db.add(db_house)
db.commit() db.commit()
except Exception: except Exception:

View File

@ -16,21 +16,18 @@ def dev_users() -> list[dict]:
"password": "admin", "password": "admin",
"name": "Sam", "name": "Sam",
"is_admin": True, "is_admin": True,
"is_lock": False,
}, },
{ {
"username": "duy", "username": "duy",
"password": "admin", "password": "admin",
"name": "Duy", "name": "Duy",
"is_admin": True, "is_admin": True,
"is_lock": False,
}, },
{ {
"username": "sam1", "username": "sam1",
"password": "admin", "password": "admin",
"name": "Sam1", "name": "Sam1",
"is_admin": False, "is_admin": False,
"is_lock": False,
}, },
] ]

View File

@ -1,11 +1,13 @@
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.core.config import get_app_settings from backend.core.config import get_app_settings
from backend.core.dependencies import is_logged_in from backend.core.dependencies import is_logged_in
from backend.core.message_code import MessageCode
from backend.db.db_setup import generate_session from backend.db.db_setup import generate_session
from backend.schemas.common import ReturnValue from backend.schemas.common import ReturnValue
from backend.schemas.house import HouseCreate from backend.schemas.house import HouseCreate
from backend.schemas.house.house import HousesListResponse
from backend.schemas.user import ProfileResponse from backend.schemas.user import ProfileResponse
from backend.services.house import HouseService from backend.services.house import HouseService
@ -16,6 +18,16 @@ settings = get_app_settings()
db_dependency = Annotated[Session, Depends(generate_session)] db_dependency = Annotated[Session, Depends(generate_session)]
current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)] current_user_token = Annotated[ProfileResponse, Depends(is_logged_in)]
@public_router.get("/create", response_model=ReturnValue[Any]) @public_router.post("/create", response_model=ReturnValue[Any])
def create_house(house: HouseCreate, db: db_dependency, current_user: current_user_token) -> ReturnValue[Any]: def create_house(house: HouseCreate, db: db_dependency, current_user: current_user_token) -> ReturnValue[Any]:
return ReturnValue(status=200, data="created") try:
house_service.create(db=db, house=house)
except Exception:
raise HTTPException(status_code=400, detail=MessageCode.CREATE_HOUSE_FAIL)
return ReturnValue(status=200, data=MessageCode.CREATE_HOUSE_SUCCESS)
@public_router.get("/all", response_model=ReturnValue[HousesListResponse])
async def get_all_house(page: int, pageSize: int, current_user: current_user_token) -> ReturnValue[HousesListResponse]:
housesCount = house_service.get_all_count(skip=page-1, limit=pageSize)
houses = house_service.get_all(skip=page-1, limit=pageSize)
return ReturnValue(status=200, data={'total': housesCount, 'list': list(houses)})

View File

@ -23,7 +23,7 @@ def register_user(user: UserCreate, db: db_dependency) -> ReturnValue[Any]:
if db_user: if db_user:
raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER) raise HTTPException(status_code=400, detail=MessageCode.CREATED_USER)
user_service.create(db=db, user=user) user_service.create(db=db, user=user)
return ReturnValue(status=200, data="created") return ReturnValue(status=200, data=MessageCode.CREATE_USER_SUCCESS)
@public_router.get("/me", response_model=ReturnValue[ProfileResponse]) @public_router.get("/me", response_model=ReturnValue[ProfileResponse])
def get_user(current_user: current_user_token) -> ReturnValue[Any]: def get_user(current_user: current_user_token) -> ReturnValue[Any]:

View File

@ -1,3 +1,7 @@
from datetime import datetime
from uuid import UUID
from pydantic import ConfigDict
from backend.schemas.main_model import MainModel from backend.schemas.main_model import MainModel
class HouseBase(MainModel): class HouseBase(MainModel):
@ -15,3 +19,14 @@ class HouseCreate(HouseBase):
name: str name: str
address: str address: str
areas: list[AreaCreate] areas: list[AreaCreate]
model_config = ConfigDict(from_attributes=True)
class HousesList(HouseCreate):
id: UUID
created_at: datetime
updated_at: datetime
deleted_at: datetime | None
class HousesListResponse(MainModel):
total: int
list: list[HousesList]

View File

@ -27,13 +27,12 @@ class UserProfile(MainModel):
class UserSeeds(UserCreate): class UserSeeds(UserCreate):
is_admin: bool is_admin: bool
is_lock: bool
class PrivateUser(UserBase): class PrivateUser(UserBase):
id: UUID id: UUID
name: str name: str
is_admin: bool is_admin: bool
is_lock: bool is_lock: datetime
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@ -9,3 +9,9 @@ class HouseService(BaseService):
def create(self, db: Session, house: HouseCreate): def create(self, db: Session, house: HouseCreate):
return self.repos.create(db=db, house=house) return self.repos.create(db=db, house=house)
def get_all(self, skip: int = 0, limit: int = 100):
return self.repos.get_all(skip=skip, limit=limit)
def get_all_count(self, skip: int = 0, limit: int = 100):
return self.repos.get_count_all(skip=skip, limit=limit)

16
frontend/src/api/house.js Normal file
View File

@ -0,0 +1,16 @@
import { protocol } from './index'
import { GET_HOUSES_LIST, POST_HOUSE_CREATE } from './url'
export const postCreateHouse = (payload) => {
return protocol.post(POST_HOUSE_CREATE, payload)
}
export const getAllHouse = ({ page, pageSize }) => {
return protocol.get(
GET_HOUSES_LIST({
page: page(),
pageSize: pageSize(),
}),
{},
)
}

View File

@ -3,3 +3,6 @@ export const POST_LOGOUT = '/api/auth/logout'
export const POST_REFRESH = '/api/auth/refresh' export const POST_REFRESH = '/api/auth/refresh'
export const GET_USER_PROFILE = '/api/user/me' export const GET_USER_PROFILE = '/api/user/me'
export const PUT_UPDATE_USER_PROFILE = '/api/user/update-profile' export const PUT_UPDATE_USER_PROFILE = '/api/user/update-profile'
export const POST_HOUSE_CREATE = '/api/house/create'
export const GET_HOUSES_LIST = ({ page, pageSize }) =>
`/api/house/all?page=${page}&pageSize=${pageSize}`

View File

@ -4,7 +4,6 @@ import Textarea from '@components/common/Textarea'
import { createForm } from '@felte/solid' import { createForm } from '@felte/solid'
import { validator } from '@felte/validator-yup' import { validator } from '@felte/validator-yup'
import useLanguage from '@hooks/useLanguage' import useLanguage from '@hooks/useLanguage'
import useToast from '@hooks/useToast'
import { import {
IconAddressBook, IconAddressBook,
IconCirclePlus, IconCirclePlus,
@ -33,7 +32,6 @@ export default function AreaAdd(props) {
const [openModal, setOpenModal] = createSignal(false) const [openModal, setOpenModal] = createSignal(false)
const [data, setData] = createSignal([]) const [data, setData] = createSignal([])
const { language, isRequired } = useLanguage() const { language, isRequired } = useLanguage()
const notify = useToast()
const { form, reset, errors } = createForm({ const { form, reset, errors } = createForm({
extend: [validator({ schema: areaSchema(language, isRequired) })], extend: [validator({ schema: areaSchema(language, isRequired) })],
onSubmit: async (values) => { onSubmit: async (values) => {
@ -54,8 +52,6 @@ export default function AreaAdd(props) {
setData((prev) => [...prev.slice(0, index), ...prev.slice(index + 1)]) setData((prev) => [...prev.slice(0, index), ...prev.slice(index + 1)])
} }
// console.log(error())
return ( return (
<div class="form-control mb-3"> <div class="form-control mb-3">
<div class="join join-vertical"> <div class="join join-vertical">
@ -95,7 +91,7 @@ export default function AreaAdd(props) {
{(item, index) => ( {(item, index) => (
<AreaItem <AreaItem
{...item} {...item}
name={props.name} formName={props.name}
key={index()} key={index()}
onDelete={onDeleteAreaItem} onDelete={onDeleteAreaItem}
/> />

View File

@ -9,12 +9,12 @@ export default function AreaItem(props) {
<p class="text-xs">{props.description}</p> <p class="text-xs">{props.description}</p>
<input <input
type="hidden" type="hidden"
name={`${props.name}.${props.key}.name`} name={`${props.formName}.${props.key}.name`}
value={props.name} value={props.name}
/> />
<input <input
type="hidden" type="hidden"
name={`${props.name}.${props.key}.desc`} name={`${props.formName}.${props.key}.desc`}
value={props.description} value={props.description}
/> />
</div> </div>

View File

@ -5,6 +5,7 @@ import useAuth from '@hooks/useAuth'
import useToast from '@hooks/useToast' import useToast from '@hooks/useToast'
import { A } from '@solidjs/router' import { A } from '@solidjs/router'
import { IconLogout, IconMenuDeep, IconUserCircle } from '@tabler/icons-solidjs' import { IconLogout, IconMenuDeep, IconUserCircle } from '@tabler/icons-solidjs'
import { Helpers } from '@utils/helper'
import { Show, onMount } from 'solid-js' import { Show, onMount } from 'solid-js'
export default function Header() { export default function Header() {
@ -71,7 +72,7 @@ export default function Header() {
</span> </span>
</li> </li>
<li class="mb-1"> <li class="mb-1">
<A href="/me"> <A href={Helpers.getRoutePath('profile')}>
<IconUserCircle size={15} /> <IconUserCircle size={15} />
Profile Profile
</A> </A>

View File

@ -5,9 +5,10 @@ import { A } from '@solidjs/router'
import { import {
IconBuildingWarehouse, IconBuildingWarehouse,
IconDashboard, IconDashboard,
IconHome, IconMapPin,
IconTriangle, IconTriangle,
} from '@tabler/icons-solidjs' } from '@tabler/icons-solidjs'
import { Helpers } from '@utils/helper'
import { For, Show, mergeProps } from 'solid-js' import { For, Show, mergeProps } from 'solid-js'
import { Dynamic } from 'solid-js/web' import { Dynamic } from 'solid-js/web'
import './navbar.scss' import './navbar.scss'
@ -16,19 +17,19 @@ const { language } = useLanguage()
export const NAV_ITEM = (admin = false) => [ export const NAV_ITEM = (admin = false) => [
{ {
path: '/dashboard', pathName: 'dashboard',
show: admin, show: admin,
icon: IconDashboard, icon: IconDashboard,
text: language?.ui.dashboard, text: language?.ui.dashboard,
}, },
{ {
path: '/house', pathName: 'location',
show: true, show: true,
icon: IconHome, icon: IconMapPin,
text: language?.ui.house, text: language?.ui.house,
}, },
{ {
path: '/warehouse', pathName: 'warehouse',
show: true, show: true,
icon: IconBuildingWarehouse, icon: IconBuildingWarehouse,
text: language?.ui.location, text: language?.ui.location,
@ -39,7 +40,7 @@ function NavbarItem(props) {
const merged = mergeProps({ active: true }, props) const merged = mergeProps({ active: true }, props)
return ( return (
<Show <Show
when={merged.active && merged.path} when={merged.active && merged.pathName}
fallback={ fallback={
<> <>
<Dynamic component={merged.icon} /> <Dynamic component={merged.icon} />
@ -47,7 +48,10 @@ function NavbarItem(props) {
</> </>
} }
> >
<A class="hover:text-fu-black" href={merged.path}> <A
class="hover:text-fu-black"
href={Helpers.getRoutePath(merged.pathName)}
>
<Dynamic component={merged.icon} /> <Dynamic component={merged.icon} />
{merged.text} {merged.text}
</A> </A>
@ -126,7 +130,7 @@ export default function Navbar() {
</span> </span>
</li> </li>
<li class="mb-1"> <li class="mb-1">
<A href="/me">Profile</A> <A href={Helpers.getRoutePath('profile')}>Profile</A>
</li> </li>
<li> <li>
<a onClick={logOut}>Logout</a> <a onClick={logOut}>Logout</a>

View File

@ -0,0 +1,64 @@
import { DOTS, usePagination } from '@hooks/usePagination'
import { For, Show, splitProps } from 'solid-js'
export default function Pagination(props) {
const [localProps, onEvents] = splitProps(
props,
['currentPage', 'totalCount', 'pageSize'],
['onPageChange'],
)
const paginationRange = usePagination(localProps)
const onNext = () => {
onEvents.onPageChange(localProps.currentPage() + 1)
}
const onPrevious = () => {
onEvents.onPageChange(localProps.currentPage() - 1)
}
return (
<Show when={localProps.currentPage !== 0 || paginationRange().length > 1}>
<div class="pagination join">
<button
class="join-item btn btn-sm border border-gray-300"
onClick={onPrevious}
>
«
</button>
<For each={paginationRange()}>
{(page) => {
if (page === DOTS) {
return (
<button
class="join-item btn btn-sm hidden lg:block border !border-gray-300"
disabled
>
...
</button>
)
}
return (
<button
class="join-item btn btn-sm hidden lg:block border border-gray-300"
classList={{
'!block btn-primary': page === localProps.currentPage(),
}}
onClick={[onEvents.onPageChange, page]}
>
{page}
</button>
)
}}
</For>
<button
class="join-item btn btn-sm border border-gray-300"
onClick={onNext}
>
»
</button>
</div>
</Show>
)
}

View File

@ -0,0 +1,2 @@
export * from './Pagination'
export { default } from './Pagination'

View File

@ -0,0 +1,60 @@
import { createMemo } from 'solid-js'
export const DOTS = '...'
const range = (start, end) => {
let length = end - start + 1
return Array.from({ length }, (_, idx) => idx + start)
}
export const usePagination = ({
totalCount,
pageSize,
currentPage,
siblingCount = 1,
}) => {
const paginationRange = createMemo(() => {
const totalPageCount = Math.ceil(totalCount / pageSize())
const totalPageNumbers = siblingCount + 5
if (totalPageNumbers >= totalPageCount) {
return range(1, totalPageCount)
}
const leftSiblingIndex = Math.max(currentPage() - siblingCount, 1)
const rightSiblingIndex = Math.min(
currentPage() + siblingCount,
totalPageCount,
)
const shouldShowLeftDots = leftSiblingIndex > 2
const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2
const firstPageIndex = 1
const lastPageIndex = totalPageCount
if (!shouldShowLeftDots && shouldShowRightDots) {
let leftItemCount = 3 + 2 * siblingCount
let leftRange = range(1, leftItemCount)
return [...leftRange, DOTS, totalPageCount]
}
if (shouldShowLeftDots && !shouldShowRightDots) {
let rightItemCount = 3 + 2 * siblingCount
let rightRange = range(
totalPageCount - rightItemCount + 1,
totalPageCount,
)
return [firstPageIndex, DOTS, ...rightRange]
}
if (shouldShowLeftDots && shouldShowRightDots) {
let middleRange = range(leftSiblingIndex, rightSiblingIndex)
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex]
}
})
return paginationRange
}

View File

@ -9,17 +9,17 @@
"changeInfo": "Thông tin tài khoản", "changeInfo": "Thông tin tài khoản",
"save": "Lưu", "save": "Lưu",
"clear": "Xóa", "clear": "Xóa",
"house": "Nhà", "house": "Địa điểm",
"action": "Thao Tác", "action": "Thao Tác",
"createNew": "Tạo mới", "createNew": "Tạo mới",
"location": "Nhà kho", "location": "Nhà kho",
"displayName": "Tên hiển thị", "displayName": "Tên hiển thị",
"newPassword": "Mật khẩu mới", "newPassword": "Mật khẩu mới",
"confirmNewPassword": "Nhập lại mật khẩu", "confirmNewPassword": "Nhập lại mật khẩu",
"newHouse": "Tạo nhà mới", "newHouse": "Tạo địa điểm mới",
"houseName": "Tên nhà", "houseName": "Tên địa điểm",
"houseIcon": "Ký tự nhà", "houseIcon": "Ký tự",
"houseAddress": "Địa chỉ nhà", "houseAddress": "Địa chỉ",
"areas": "Khu vực", "areas": "Khu vực",
"areaName": "Tên khu vực", "areaName": "Tên khu vực",
"areaDesc": "Mô tả", "areaDesc": "Mô tả",
@ -30,7 +30,12 @@
"confirm": "Xác nhận", "confirm": "Xác nhận",
"cancel": "Huỷ", "cancel": "Huỷ",
"findIconHere": "Tìm ở đây", "findIconHere": "Tìm ở đây",
"empty": "Trống" "empty": "Trống",
"error": "lỗi!",
"success": "Thành công!",
"info": "Thông tin",
"loading": "Đang tải...",
"showing": "Hiển thị"
}, },
"table": { "table": {
"columnName": { "columnName": {
@ -42,10 +47,17 @@
} }
}, },
"message": { "message": {
"CREATED_USER": "Username already registered!", "CREATE_USER_SUCCESS": "Tạo tài khoản thành công!",
"LOGIN_WRONG": "Your username or password input is wrong!", "CREATED_USER": "Tên tài khoản đã có người sử dụng!",
"USER_LOCK": "Your Account was locked", "LOGIN_WRONG": "Bạn nhập sai tên người dùng hoặc mật khẩu.",
"IS_REQUIRED": "%s là bắt buộc", "USER_LOCK": "Tài khoản bạn hiện đang bị khóa.",
"PASSWORD_MUSTMATCH": "Cần nhập trùng với mật khẩu" "IS_REQUIRED": "%s là bắt buộc.",
"PASSWORD_MUSTMATCH": "Cần nhập trùng với mật khẩu.",
"UPDATE_SUCCESS": "Cập nhật thành công!",
"UPDATE_PROFILE_SUCCESS": "Cập nhật hồ sơ thành công!",
"UPDATE_FAIL": "Cập nhật thất bại!",
"API_CALL_FAIL": "Call API có vẫn đề!",
"CREATE_HOUSE_FAIL": "Tạo địa điểm mới thất bại!",
"CREATE_HOUSE_SUCCESS": "Tạo địa điểm mới thành công!"
} }
} }

View File

@ -1,6 +1,7 @@
import { NAV_ITEM } from '@components/Navbar' import { NAV_ITEM } from '@components/Navbar'
import { useSiteContext } from '@context/SiteContext' import { useSiteContext } from '@context/SiteContext'
import { useNavigate } from '@solidjs/router' import { useNavigate } from '@solidjs/router'
import { Helpers } from '@utils/helper'
import { createEffect } from 'solid-js' import { createEffect } from 'solid-js'
function getFirstItem(array) { function getFirstItem(array) {
@ -18,7 +19,12 @@ export default function Home() {
createEffect(() => { createEffect(() => {
if (store?.userInfo?.isAdmin) { if (store?.userInfo?.isAdmin) {
const first = getFirstItem(NAV_ITEM(store?.userInfo?.isAdmin)) const first = getFirstItem(NAV_ITEM(store?.userInfo?.isAdmin))
navigate(first ? first.path : '/me', { replace: true }) navigate(
first
? Helpers.getRoutePath(first.pathName)
: Helpers.getRoutePath('profile'),
{ replace: true },
)
} }
}) })

View File

@ -1,8 +1,10 @@
import ViewSwitch, { VIEWDATA } from '@components/ViewSwitch' import ViewSwitch, { VIEWDATA } from '@components/ViewSwitch'
import useLanguage from '@hooks/useLanguage' import useLanguage from '@hooks/useLanguage'
import { IconHome } from '@tabler/icons-solidjs' import { IconHome } from '@tabler/icons-solidjs'
import { createSignal } from 'solid-js' import { For, createEffect, createResource, createSignal } from 'solid-js'
import { getAllHouse } from '@api/house'
import Pagination from '@components/common/Pagination'
import { A } from '@solidjs/router' import { A } from '@solidjs/router'
import { import {
IconHome2, IconHome2,
@ -13,9 +15,28 @@ import {
} from '@tabler/icons-solidjs' } from '@tabler/icons-solidjs'
import './house.scss' import './house.scss'
const PAGE_SIZE = [10, 50, 100]
const fetchHouses = async ({ page, pageSize }) => {
const response = await getAllHouse({ page, pageSize })
return response
}
export default function House() { export default function House() {
const { language } = useLanguage() const { language } = useLanguage()
const [pageSize, setPageSize] = createSignal(PAGE_SIZE[0])
const [currentPage, setCurrentPage] = createSignal(1)
const [view, setView] = createSignal(VIEWDATA['list']) const [view, setView] = createSignal(VIEWDATA['list'])
const [houses] = createResource(
{ page: currentPage, pageSize: pageSize },
fetchHouses,
)
createEffect(() => {
if (houses()) {
console.log(houses()?.data)
}
})
const onEdit = () => { const onEdit = () => {
console.log('edit') console.log('edit')
@ -25,6 +46,11 @@ export default function House() {
console.log('delete') console.log('delete')
} }
const onSetPageSize = (pageSize) => {
setPageSize(pageSize)
setCurrentPage(1)
}
return ( return (
<div class="house"> <div class="house">
<div class="flex items-center gap-2 mb-5 text-xl"> <div class="flex items-center gap-2 mb-5 text-xl">
@ -36,7 +62,7 @@ export default function House() {
<div class="page-topbar flex justify-between mb-4"> <div class="page-topbar flex justify-between mb-4">
<ViewSwitch switchView={setView} /> <ViewSwitch switchView={setView} />
<A <A
href="/house/create" href="/location/create"
class="btn btn-success text-white hover:text-white btn-sm" class="btn btn-success text-white hover:text-white btn-sm"
> >
<IconSquareRoundedPlus size={15} /> <IconSquareRoundedPlus size={15} />
@ -104,6 +130,42 @@ export default function House() {
</div> </div>
</div> </div>
</div> </div>
<div class="page-botbar flex max-xs:flex-col justify-between items-center gap-2 mt-5">
<div class="bar-left flex gap-2 justify-start items-center">
<div class="py-2 px-3 border rounded-lg border-gray-300 leading-none">
<span>{language.ui.showing}: 1 - 10 / 100</span>
</div>
<div class="dropdown dropdown-top dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-sm border border-gray-300"
>
{pageSize()}
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-32 p-2 shadow mb-2"
>
<For each={PAGE_SIZE.reverse()}>
{(pageSize) => (
<li>
<a onClick={[onSetPageSize, pageSize]}>{pageSize}</a>
</li>
)}
</For>
</ul>
</div>
</div>
<div class="bar-right">
<Pagination
currentPage={currentPage}
totalCount={1000}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div> </div>
) )
} }

View File

@ -1,15 +1,18 @@
import { postCreateHouse } from '@api/house'
import AreaAdd from '@components/AreaAdd' import AreaAdd from '@components/AreaAdd'
import TextInput from '@components/common/TextInput' import TextInput from '@components/common/TextInput'
import { createForm } from '@felte/solid' import { createForm } from '@felte/solid'
import { validator } from '@felte/validator-yup' import { validator } from '@felte/validator-yup'
import useLanguage from '@hooks/useLanguage' import useLanguage from '@hooks/useLanguage'
import { A } from '@solidjs/router' import useToast from '@hooks/useToast'
import { A, useNavigate } from '@solidjs/router'
import { import {
IconAddressBook, IconAddressBook,
IconHomePlus,
IconIcons, IconIcons,
IconMapPinPlus,
IconTag, IconTag,
} from '@tabler/icons-solidjs' } from '@tabler/icons-solidjs'
import { Helpers } from '@utils/helper'
import * as yup from 'yup' import * as yup from 'yup'
const houseSchema = (language, isRequired) => const houseSchema = (language, isRequired) =>
@ -17,15 +20,41 @@ const houseSchema = (language, isRequired) =>
icon: yup.string().required(isRequired(language.ui.houseIcon)), icon: yup.string().required(isRequired(language.ui.houseIcon)),
name: yup.string().required(isRequired(language.ui.houseName)), name: yup.string().required(isRequired(language.ui.houseName)),
address: yup.string().required(isRequired(language.ui.houseAddress)), address: yup.string().required(isRequired(language.ui.houseAddress)),
areas: yup.array().required(isRequired(language.ui.areas)), areas: yup
.array()
.min(1, isRequired(language.ui.areas))
.required(isRequired(language.ui.areas)),
}) })
export default function HouseCreate() { export default function HouseCreate() {
const { language, isRequired } = useLanguage() const { language, isRequired } = useLanguage()
const notify = useToast()
const navigate = useNavigate()
const { form, errors } = createForm({ const { form, errors } = createForm({
extend: [validator({ schema: houseSchema(language, isRequired) })], extend: [validator({ schema: houseSchema(language, isRequired) })],
onSubmit: async (values) => { onSubmit: async (values) => {
console.log(values) console.log(values)
const resp = await postCreateHouse(values)
return resp
},
onSuccess: (resp) => {
if (resp.status === 200) {
notify.success({
title: language.ui.success,
description:
language.message[resp.data] ||
language.message['CREATE_HOUSE_SUCCESS'],
})
navigate(Helpers.getRoutePath('location'), { replace: true })
}
},
onError: (error) => {
notify.error({
title: language.ui.error,
description:
language.message[error.data] || language.message['API_CALL_FAIL'],
closable: true,
})
}, },
}) })
@ -34,14 +63,14 @@ export default function HouseCreate() {
<div class="text-sm breadcrumbs mb-2"> <div class="text-sm breadcrumbs mb-2">
<ul> <ul>
<li> <li>
<A href="/house">{language.ui.house}</A> <A href={Helpers.getRoutePath('location')}>{language.ui.house}</A>
</li> </li>
<li>{language.ui.newHouse}</li> <li>{language.ui.newHouse}</li>
</ul> </ul>
</div> </div>
<div class="flex items-center gap-2 mb-5 text-xl"> <div class="flex items-center gap-2 mb-5 text-xl">
<span class="text-secondary"> <span class="text-secondary">
<IconHomePlus size={30} /> <IconMapPinPlus size={30} />
</span> </span>
{language.ui.newHouse} {language.ui.newHouse}
</div> </div>
@ -90,7 +119,13 @@ export default function HouseCreate() {
/> />
</div> </div>
<div class="col-auto lg:col-span-2"> <div class="col-auto lg:col-span-2">
<AreaAdd name="areas" error={errors('areas')} /> <AreaAdd
name="areas"
error={
errors('areas') &&
Helpers.clearArrayWithNullObject(errors('areas'))
}
/>
</div> </div>
</div> </div>
<div class="card-actions"> <div class="card-actions">

View File

@ -21,7 +21,7 @@ export default function Layout(props) {
<div class="drawer lg:drawer-open"> <div class="drawer lg:drawer-open">
<input id="nav-menu" type="checkbox" class="drawer-toggle" /> <input id="nav-menu" type="checkbox" class="drawer-toggle" />
<div class="drawer-content flex flex-col"> <div class="drawer-content flex flex-col">
<main class="main-content p-3">{props.children}</main> <main class="main-content p-3 pb-5">{props.children}</main>
</div> </div>
<Navbar /> <Navbar />
</div> </div>

View File

@ -46,16 +46,17 @@ export default function Profile() {
setUser(resp.data) setUser(resp.data)
reset() reset()
notify.success({ notify.success({
title: 'Update profile success!', title: language.ui.success,
description: 'Your profile has been updated!', description:
language.message[resp.data] ||
language.message['CREATE_USER_SUCCESS'],
}) })
} }
} catch (error) { } catch (error) {
notify.error({ notify.error({
title: 'Update profile fail!', title: language.ui.error,
description: error?.data description:
? language.message[error.data] language.message[error?.data] || language.message['API_CALL_FAIL'],
: 'Your username or password input is wrong!',
closable: true, closable: true,
}) })
} }

View File

@ -2,36 +2,42 @@ import { lazy } from 'solid-js'
export const ROUTES = [ export const ROUTES = [
{ {
name: 'home',
path: '/', path: '/',
components: lazy(() => import('@pages/Home')), components: lazy(() => import('@pages/Home')),
filter: {}, filter: {},
show: true, show: true,
}, },
{ {
name: 'profile',
path: '/me', path: '/me',
components: lazy(() => import('@pages/Profile')), components: lazy(() => import('@pages/Profile')),
filter: {}, filter: {},
show: true, show: true,
}, },
{ {
name: 'dashboard',
path: '/dashboard', path: '/dashboard',
components: lazy(() => import('@pages/Dashboard')), components: lazy(() => import('@pages/Dashboard')),
filter: {}, filter: {},
show: true, show: true,
}, },
{ {
path: '/house', name: 'location',
path: '/location',
components: lazy(() => import('@pages/House')), components: lazy(() => import('@pages/House')),
filter: {}, filter: {},
show: true, show: true,
}, },
{ {
path: '/house/create', name: 'create-location',
path: '/location/create',
components: lazy(() => import('@pages/HouseCreate')), components: lazy(() => import('@pages/HouseCreate')),
filter: {}, filter: {},
show: true, show: true,
}, },
{ {
name: 'warehouse',
path: '/warehouse', path: '/warehouse',
components: lazy(() => import('@pages/WareHouse')), components: lazy(() => import('@pages/WareHouse')),
filter: {}, filter: {},

View File

@ -1,3 +1,4 @@
import { ROUTES } from '@routes/routes'
import { AES, enc } from 'crypto-js' import { AES, enc } from 'crypto-js'
import { LOGIN_KEY, SECRET_KEY, STORE_KEY } from './enum' import { LOGIN_KEY, SECRET_KEY, STORE_KEY } from './enum'
@ -58,16 +59,18 @@ export class Helpers {
} }
static clearArrayWithNullObject = (array) => { static clearArrayWithNullObject = (array) => {
console.log(array) if (array instanceof Array) {
array.forEach((element, i) => { array.forEach((element, i) => {
const obk = this.clearObject(element) if (element instanceof Object) {
if (obk) array.splice(i, 1) const obk = this.clearObject(element)
}) if (obk) array.splice(i, 1)
// for (let i = 0; i < array.length; i++) { }
// if (array[i] === null || array[i] === undefined) { })
// array.splice(i, 1) }
// }
// }
return array.length > 0 ? array : null return array.length > 0 ? array : null
} }
static getRoutePath = (pathName) =>
ROUTES.filter((r) => r.name === pathName)[0].path
} }

View File

@ -6,6 +6,9 @@ export default {
content: ['./src/**/*.{js,jsx}'], content: ['./src/**/*.{js,jsx}'],
theme: { theme: {
extend: { extend: {
screens: {
xs: '400px',
},
colors: { colors: {
fu: { fu: {
white: '#fff', white: '#fff',