first commit

This commit is contained in:
fizipan 2025-06-28 07:59:41 +07:00
parent 3986fd953b
commit 4efeaa9dfe
39 changed files with 983 additions and 830 deletions

40
package-lock.json generated
View File

@ -31,6 +31,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.522.0",
"mantine-datatable": "^1.7.17",
"nuqs": "^2.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.58.1",
@ -16067,6 +16068,12 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@ -16241,6 +16248,39 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/nuqs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.4.3.tgz",
"integrity": "sha512-BgtlYpvRwLYiJuWzxt34q2bXu/AIS66sLU1QePIMr2LWkb+XH0vKXdbLSgn9t6p7QKzwI7f38rX3Wl9llTXQ8Q==",
"license": "MIT",
"dependencies": {
"mitt": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/franky47"
},
"peerDependencies": {
"@remix-run/react": ">=2",
"next": ">=14.2.0",
"react": ">=18.2.0 || ^19.0.0-0",
"react-router": "^6 || ^7",
"react-router-dom": "^6 || ^7"
},
"peerDependenciesMeta": {
"@remix-run/react": {
"optional": true
},
"next": {
"optional": true
},
"react-router": {
"optional": true
},
"react-router-dom": {
"optional": true
}
}
},
"node_modules/nwsapi": {
"version": "2.2.13",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz",

View File

@ -32,6 +32,7 @@
"lodash": "^4.17.21",
"lucide-react": "^0.522.0",
"mantine-datatable": "^1.7.17",
"nuqs": "^2.4.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.58.1",

View File

@ -5,9 +5,9 @@ import { IRootState } from '../../store';
import { toggleSidebar } from '../../store/themeConfigSlice';
import Footer from './Footer';
import Header from './Header';
import Setting from './Setting';
import Sidebar from './Sidebar';
import Portals from '../../components/Portals';
import IconLoader from '../Icon/IconLoader';
const DefaultLayout = ({ children }: PropsWithChildren) => {
const themeConfig = useSelector((state: IRootState) => state.themeConfig);
@ -89,7 +89,13 @@ const DefaultLayout = ({ children }: PropsWithChildren) => {
{/* END TOP NAVBAR */}
{/* BEGIN CONTENT AREA */}
<Suspense>
<Suspense
fallback={
<div className="flex h-screen w-screen items-center justify-center">
<IconLoader className="animate-spin w-16 h-16" />
</div>
}
>
<div className={`${themeConfig.animation} p-6 animate__animated`}>{children}</div>
</Suspense>
{/* END CONTENT AREA */}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
import { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { DialogPanel, TransitionChild } from '@headlessui/react';
import { useLogout } from '../../../lib/auth';
import IconX from '../../../components/Icon/IconX';
import IconLoader from '../../../components/Icon/IconLoader';
interface LogoutModalProps {
isOpen: boolean;
onClose: () => void;
}
export const LogoutModal = ({ isOpen, onClose }: LogoutModalProps) => {
const logout = useLogout();
const handleLogout = async () => {
await logout.mutateAsync({});
onClose();
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
{/* BACKDROP */}
<TransitionChild as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100" leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0">
<div className="fixed inset-0 bg-black/60" />
</TransitionChild>
{/* PANEL */}
<div className="fixed inset-0 flex items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform overflow-hidden rounded-xl bg-white p-6 text-center align-middle shadow-xl transition-all dark:bg-[#121c2c]">
{/* CLOSE */}
<button type="button" onClick={onClose} className="absolute right-4 top-4 text-gray-400 hover:text-gray-800 dark:hover:text-white">
<IconX />
</button>
<Dialog.Title as="h3" className="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
Confirm Logout
</Dialog.Title>
<div className="mt-4 text-sm text-gray-600 dark:text-gray-300">Are you sure you want to logout?</div>
<div className="mt-6 flex justify-center gap-4">
<button type="button" onClick={onClose} className="btn btn-outline-primary">
Cancel
</button>
<button type="button" onClick={handleLogout} className="btn btn-danger disabled:opacity-50" disabled={logout.isPending}>
{logout.isPending ? <IconLoader /> : 'Logout'}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
};

View File

@ -22,7 +22,7 @@ export const useCreateProductCategory = ({ mutationConfig }: UseCreateProductCat
mutationFn: createProductCategory,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductCategoriesQueryOptions().queryKey,
queryKey: [getProductCategoriesQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -21,7 +21,7 @@ export const useDeleteProductCategory = ({ mutationConfig }: UseDeleteProductCat
mutationFn: deleteProductCategory,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductCategoriesQueryOptions({}).queryKey,
queryKey: [getProductCategoriesQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -4,21 +4,21 @@ import { api } from '../../../lib/apiClient';
import { ProductCategoryResponse } from '../types/api';
type ProductCategoriesQueryParams = {
q?: string;
search?: string | null;
page?: number;
limit?: number;
};
export const getProductCategories = ({ q, page, limit }: ProductCategoriesQueryParams = {}): Promise<ProductCategoryResponse> => {
export const getProductCategories = ({ search, page, limit }: ProductCategoriesQueryParams = {}): Promise<ProductCategoryResponse> => {
return api.get('/inventory/categories', {
params: { q, page, limit },
params: { search, page, limit },
});
};
export const getProductCategoriesQueryOptions = ({ q, page, limit }: ProductCategoriesQueryParams = {}) => {
export const getProductCategoriesQueryOptions = ({ search, page, limit }: ProductCategoriesQueryParams = {}) => {
return queryOptions({
queryKey: ['productCategories', { q, page, limit }],
queryFn: () => getProductCategories({ q, page, limit }),
queryKey: ['productCategories', { search, page, limit }],
queryFn: () => getProductCategories({ search, page, limit }),
});
};
@ -26,9 +26,9 @@ type UseProductCategoriesOptions = {
queryConfig?: QueryConfig<typeof getProductCategoriesQueryOptions>;
} & ProductCategoriesQueryParams;
export const useProductCategories = ({ q, page, limit, queryConfig }: UseProductCategoriesOptions = {}) => {
export const useProductCategories = ({ search, page, limit, queryConfig }: UseProductCategoriesOptions = {}) => {
return useQuery({
...getProductCategoriesQueryOptions({ q, page, limit }),
...getProductCategoriesQueryOptions({ search, page, limit }),
...queryConfig,
});
};

View File

@ -22,7 +22,7 @@ export const useUpdateProductCategory = ({ mutationConfig }: UseUpdateProductCat
mutationFn: updateProductCategory,
onSuccess: (data, ...args) => {
queryClient.invalidateQueries({
queryKey: getProductCategoriesQueryOptions().queryKey,
queryKey: [getProductCategoriesQueryOptions({}).queryKey[0]],
});
onSuccess?.(data, ...args);
},

View File

@ -8,6 +8,8 @@ import { ProductCategory } from '../types/api';
import { EditProductCategoryModal } from './EditProductCategoryModal';
import { DeleteProductCategoryModal } from './DeleteProductCategoryModal';
import { DetailProductCategoryModal } from './DetailProductCategoryModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductCategoriesList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedCategory, setSelectedCategory] = useState<ProductCategory | null>(null);
const categoryQuery = useProductCategories();
const [searchParam, setSearchParam] = useQueryState('search');
const debouncedSearchParam = useDebounce(searchParam, 500);
const categoryQuery = useProductCategories({ search: debouncedSearchParam });
const categories = categoryQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(categories, {
@ -25,10 +30,6 @@ export const ProductCategoriesList = () => {
direction: 'desc',
});
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((category) => category.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (category: ProductCategory) => {
setSelectedCategory(category);
setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductCategoriesList = () => {
return (
<>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5">
<ProductCategoryListHeader search={search} setSearch={setSearch} />
<ProductCategoryListHeader search={searchParam} setSearch={setSearchParam} />
<div className="datatables pagination-padding px-5">
<DataTable
className="whitespace-nowrap table-hover"
records={filteredRecords}
records={paginatedRecords}
columns={productCategoryColumns({ onEdit, onView, onDelete })}
highlightOnHover
minHeight={200}

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react';
import { CreateProductCategoryModal } from './CreateProductCategoryModal';
export const ProductCategoryListHeader = ({ search, setSearch }: { search: string; setSearch: (v: string) => void }) => {
export const ProductCategoryListHeader = ({ search, setSearch }: { search: string | null; setSearch: (v: string | null) => void }) => {
const { t } = useTranslation();
const [isOpenModalCreateCategory, setIsOpenModalCreateCategory] = useState(false);
@ -11,7 +11,7 @@ export const ProductCategoryListHeader = ({ search, setSearch }: { search: strin
<>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto">
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search || ''} onChange={(e) => setSearch(e.target.value || null)} />
</div>
<div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateCategory(true)}>

View File

@ -22,7 +22,7 @@ export const useCreateProductCollection = ({ mutationConfig }: UseCreateProductC
mutationFn: createProductCollection,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductCollectionsQueryOptions().queryKey,
queryKey: [getProductCollectionsQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -20,7 +20,7 @@ export const useDeleteProductCollection = ({ mutationConfig }: UseDeleteProductC
mutationFn: deleteProductCollection,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductCollectionsQueryOptions().queryKey,
queryKey: [getProductCollectionsQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -4,21 +4,21 @@ import { api } from '../../../lib/apiClient';
import { ProductCollectionResponse } from '../types/api';
type ProductCollectionsQueryParams = {
q?: string;
search?: string | null;
page?: number;
limit?: number;
};
export const getProductCollections = ({ q, page, limit }: ProductCollectionsQueryParams = {}): Promise<ProductCollectionResponse> => {
export const getProductCollections = ({ search, page, limit }: ProductCollectionsQueryParams = {}): Promise<ProductCollectionResponse> => {
return api.get('/inventory/collections', {
params: { q, page, limit },
params: { search, page, limit },
});
};
export const getProductCollectionsQueryOptions = ({ q, page, limit }: ProductCollectionsQueryParams = {}) => {
export const getProductCollectionsQueryOptions = ({ search, page, limit }: ProductCollectionsQueryParams = {}) => {
return queryOptions({
queryKey: ['productCollections', { q, page, limit }],
queryFn: () => getProductCollections({ q, page, limit }),
queryKey: ['productCollections', { search, page, limit }],
queryFn: () => getProductCollections({ search, page, limit }),
});
};
@ -26,9 +26,9 @@ type UseProductCollectionsOptions = {
queryConfig?: QueryConfig<typeof getProductCollectionsQueryOptions>;
} & ProductCollectionsQueryParams;
export const useProductCollections = ({ q, page, limit, queryConfig }: UseProductCollectionsOptions = {}) => {
export const useProductCollections = ({ search, page, limit, queryConfig }: UseProductCollectionsOptions = {}) => {
return useQuery({
...getProductCollectionsQueryOptions({ q, page, limit }),
...getProductCollectionsQueryOptions({ search, page, limit }),
...queryConfig,
});
};

View File

@ -22,7 +22,7 @@ export const useUpdateProductCollection = ({ mutationConfig }: UseUpdateProductC
mutationFn: updateProductCollection,
onSuccess: (data, ...args) => {
queryClient.invalidateQueries({
queryKey: getProductCollectionsQueryOptions().queryKey,
queryKey: [getProductCollectionsQueryOptions({}).queryKey[0]],
});
onSuccess?.(data, ...args);
},

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react';
import { CreateProductCollectionModal } from './CreateProductCollectionModal';
export const ProductCollectionListHeader = ({ search, setSearch }: { search: string; setSearch: (v: string) => void }) => {
export const ProductCollectionListHeader = ({ search, setSearch }: { search: string | null; setSearch: (v: string | null) => void }) => {
const { t } = useTranslation();
const [isOpenModalCreateCollection, setIsOpenModalCreateCollection] = useState(false);
@ -11,7 +11,7 @@ export const ProductCollectionListHeader = ({ search, setSearch }: { search: str
<>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto">
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search || ''} onChange={(e) => setSearch(e.target.value || null)} />
</div>
<div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateCollection(true)}>

View File

@ -8,6 +8,8 @@ import { ProductCollection } from '../types/api';
import { DeleteProductCollectionModal } from './DeleteProductCollectionModal';
import { EditProductCollectionModal } from './EditProductCollectionModal';
import { DetailProductCollectionModal } from './DetailProductCollectionModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductCollectionsList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedCollection, setSelectedCollection] = useState<ProductCollection | null>(null);
const collectionQuery = useProductCollections();
const [searchParam, setSearchParam] = useQueryState('search');
const debouncedSearchParam = useDebounce(searchParam, 500);
const collectionQuery = useProductCollections({ search: debouncedSearchParam });
const collections = collectionQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(collections, {
@ -25,10 +30,6 @@ export const ProductCollectionsList = () => {
direction: 'desc',
});
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((collection) => collection.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (collection: ProductCollection) => {
setSelectedCollection(collection);
setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductCollectionsList = () => {
return (
<>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5">
<ProductCollectionListHeader search={search} setSearch={setSearch} />
<ProductCollectionListHeader search={searchParam} setSearch={setSearchParam} />
<div className="datatables pagination-padding px-5">
<DataTable
className="whitespace-nowrap table-hover"
records={filteredRecords}
records={paginatedRecords}
columns={productCollectionColumns({ onEdit, onView, onDelete })}
highlightOnHover
minHeight={200}

View File

@ -22,7 +22,7 @@ export const useCreateProductColour = ({ mutationConfig }: UseCreateProductColou
mutationFn: createProductColour,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductColoursQueryOptions().queryKey,
queryKey: [getProductColoursQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -20,7 +20,7 @@ export const useDeleteProductColour = ({ mutationConfig }: UseDeleteProductColou
mutationFn: deleteProductColour,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductColoursQueryOptions().queryKey,
queryKey: [getProductColoursQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -4,21 +4,21 @@ import { api } from '../../../lib/apiClient';
import { ProductColourResponse } from '../types/api';
type ProductColoursQueryParams = {
q?: string;
search?: string | null;
page?: number;
limit?: number;
};
export const getProductColours = ({ q, page, limit }: ProductColoursQueryParams = {}): Promise<ProductColourResponse> => {
export const getProductColours = ({ search, page, limit }: ProductColoursQueryParams = {}): Promise<ProductColourResponse> => {
return api.get('/inventory/colours', {
params: { q, page, limit },
params: { search, page, limit },
});
};
export const getProductColoursQueryOptions = ({ q, page, limit }: ProductColoursQueryParams = {}) => {
export const getProductColoursQueryOptions = ({ search, page, limit }: ProductColoursQueryParams = {}) => {
return queryOptions({
queryKey: ['productColours', { q, page, limit }],
queryFn: () => getProductColours({ q, page, limit }),
queryKey: ['productColours', { search, page, limit }],
queryFn: () => getProductColours({ search, page, limit }),
});
};
@ -26,9 +26,9 @@ type UseProductColoursOptions = {
queryConfig?: QueryConfig<typeof getProductColoursQueryOptions>;
} & ProductColoursQueryParams;
export const useProductColours = ({ q, page, limit, queryConfig }: UseProductColoursOptions = {}) => {
export const useProductColours = ({ search, page, limit, queryConfig }: UseProductColoursOptions = {}) => {
return useQuery({
...getProductColoursQueryOptions({ q, page, limit }),
...getProductColoursQueryOptions({ search, page, limit }),
...queryConfig,
});
};

View File

@ -22,7 +22,7 @@ export const useUpdateProductColour = ({ mutationConfig }: UseUpdateProductColou
mutationFn: updateProductColour,
onSuccess: (data, ...args) => {
queryClient.invalidateQueries({
queryKey: getProductColoursQueryOptions().queryKey,
queryKey: [getProductColoursQueryOptions({}).queryKey[0]],
});
onSuccess?.(data, ...args);
},

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react';
import { CreateProductColourModal } from './CreateProductColourModal';
export const ProductColourListHeader = ({ search, setSearch }: { search: string; setSearch: (v: string) => void }) => {
export const ProductColourListHeader = ({ search, setSearch }: { search: string | null; setSearch: (v: string | null) => void }) => {
const { t } = useTranslation();
const [isOpenModalCreateColour, setIsOpenModalCreateColour] = useState(false);
@ -11,7 +11,7 @@ export const ProductColourListHeader = ({ search, setSearch }: { search: string;
<>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto">
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search || ''} onChange={(e) => setSearch(e.target.value || null)} />
</div>
<div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateColour(true)}>

View File

@ -8,6 +8,8 @@ import { ProductColour } from '../types/api';
import { DeleteProductColourModal } from './DeleteProductColourModal';
import { EditProductColourModal } from './EditProductColourModal';
import { DetailProductColourModal } from './DetailProductColourModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductColoursList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedColour, setSelectedColour] = useState<ProductColour | null>(null);
const colourQuery = useProductColours();
const [searchParam, setSearchParam] = useQueryState('search');
const debouncedSearchParam = useDebounce(searchParam, 500);
const colourQuery = useProductColours({ search: debouncedSearchParam });
const colours = colourQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(colours, {
@ -25,10 +30,6 @@ export const ProductColoursList = () => {
direction: 'desc',
});
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((colour) => colour.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (colour: ProductColour) => {
setSelectedColour(colour);
setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductColoursList = () => {
return (
<>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5">
<ProductColourListHeader search={search} setSearch={setSearch} />
<ProductColourListHeader search={searchParam} setSearch={setSearchParam} />
<div className="datatables pagination-padding px-5">
<DataTable
className="whitespace-nowrap table-hover"
records={filteredRecords}
records={paginatedRecords}
columns={productColourColumns({ onEdit, onView, onDelete })}
highlightOnHover
minHeight={200}

View File

@ -22,7 +22,7 @@ export const useCreateProductSize = ({ mutationConfig }: UseCreateProductSizeOpt
mutationFn: createProductSize,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductSizesQueryOptions().queryKey,
queryKey: [getProductSizesQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -20,7 +20,7 @@ export const useDeleteProductSize = ({ mutationConfig }: UseDeleteProductSizeOpt
mutationFn: deleteProductSize,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductSizesQueryOptions().queryKey,
queryKey: [getProductSizesQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -4,21 +4,21 @@ import { api } from '../../../lib/apiClient';
import { ProductSizeResponse } from '../types/api';
type ProductSizesQueryParams = {
q?: string;
search?: string | null;
page?: number;
limit?: number;
};
export const getProductSizes = ({ q, page, limit }: ProductSizesQueryParams = {}): Promise<ProductSizeResponse> => {
export const getProductSizes = ({ search, page, limit }: ProductSizesQueryParams = {}): Promise<ProductSizeResponse> => {
return api.get('/inventory/sizes', {
params: { q, page, limit },
params: { search, page, limit },
});
};
export const getProductSizesQueryOptions = ({ q, page, limit }: ProductSizesQueryParams = {}) => {
export const getProductSizesQueryOptions = ({ search, page, limit }: ProductSizesQueryParams = {}) => {
return queryOptions({
queryKey: ['productSizes', { q, page, limit }],
queryFn: () => getProductSizes({ q, page, limit }),
queryKey: ['productSizes', { search, page, limit }],
queryFn: () => getProductSizes({ search, page, limit }),
});
};
@ -26,9 +26,9 @@ type UseProductSizesOptions = {
queryConfig?: QueryConfig<typeof getProductSizesQueryOptions>;
} & ProductSizesQueryParams;
export const useProductSizes = ({ q, page, limit, queryConfig }: UseProductSizesOptions = {}) => {
export const useProductSizes = ({ search, page, limit, queryConfig }: UseProductSizesOptions = {}) => {
return useQuery({
...getProductSizesQueryOptions({ q, page, limit }),
...getProductSizesQueryOptions({ search, page, limit }),
...queryConfig,
});
};

View File

@ -22,7 +22,7 @@ export const useUpdateProductSize = ({ mutationConfig }: UseUpdateProductSizeOpt
mutationFn: updateProductSize,
onSuccess: (data, ...args) => {
queryClient.invalidateQueries({
queryKey: getProductSizesQueryOptions().queryKey,
queryKey: [getProductSizesQueryOptions({}).queryKey[0]],
});
onSuccess?.(data, ...args);
},

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react';
import { CreateProductSizeModal } from './CreateProductSizeModal';
export const ProductSizeListHeader = ({ search, setSearch }: { search: string; setSearch: (v: string) => void }) => {
export const ProductSizeListHeader = ({ search, setSearch }: { search: string | null; setSearch: (v: string | null) => void }) => {
const { t } = useTranslation();
const [isOpenModalCreateSize, setIsOpenModalCreateSize] = useState(false);
@ -11,7 +11,7 @@ export const ProductSizeListHeader = ({ search, setSearch }: { search: string; s
<>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto">
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search || ''} onChange={(e) => setSearch(e.target.value || null)} />
</div>
<div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateSize(true)}>

View File

@ -8,6 +8,8 @@ import { ProductSize } from '../types/api';
import { DeleteProductSizeModal } from './DeleteProductSizeModal';
import { EditProductSizeModal } from './EditProductSizeModal';
import { DetailProductSizeModal } from './DetailProductSizeModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductSizesList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedSize, setSelectedSize] = useState<ProductSize | null>(null);
const sizeQuery = useProductSizes();
const [searchParam, setSearchParam] = useQueryState('search');
const debouncedSearchParam = useDebounce(searchParam, 500);
const sizeQuery = useProductSizes({ search: debouncedSearchParam });
const sizes = sizeQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(sizes, {
@ -25,10 +30,6 @@ export const ProductSizesList = () => {
direction: 'desc',
});
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((size) => size.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (size: ProductSize) => {
setSelectedSize(size);
setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductSizesList = () => {
return (
<>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5">
<ProductSizeListHeader search={search} setSearch={setSearch} />
<ProductSizeListHeader search={searchParam} setSearch={setSearchParam} />
<div className="datatables pagination-padding px-5">
<DataTable
className="whitespace-nowrap table-hover"
records={filteredRecords}
records={paginatedRecords}
columns={productSizeColumns({ onEdit, onView, onDelete })}
highlightOnHover
minHeight={200}

View File

@ -22,7 +22,7 @@ export const useCreateProduct = ({ mutationConfig }: UseCreateProductOptions = {
mutationFn: createProduct,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductsQueryOptions({}).queryKey,
queryKey: [getProductsQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -21,7 +21,7 @@ export const useDeleteProduct = ({ mutationConfig }: UseDeleteProductOptions = {
mutationFn: deleteProduct,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: getProductsQueryOptions({}).queryKey,
queryKey: [getProductsQueryOptions({}).queryKey[0]],
});
onSuccess?.(...args);
},

View File

@ -5,25 +5,25 @@ import { api } from '../../../lib/apiClient';
import { ProductResponse } from '../types/api';
type ProductsQueryParams = {
q?: string;
search?: string | null;
page?: number;
limit?: number;
};
export const getProducts = ({ q, page, limit }: ProductsQueryParams): Promise<ProductResponse> => {
export const getProducts = ({ search, page, limit }: ProductsQueryParams): Promise<ProductResponse> => {
return api.get('/inventory/products', {
params: {
q,
search,
page,
limit,
},
});
};
export const getProductsQueryOptions = ({ q, page, limit }: ProductsQueryParams) => {
export const getProductsQueryOptions = ({ search, page, limit }: ProductsQueryParams) => {
return queryOptions({
queryKey: ['products', { q, page, limit }],
queryFn: () => getProducts({ q, page, limit }),
queryKey: ['products', { search, page, limit }],
queryFn: () => getProducts({ search, page, limit }),
});
};
@ -31,9 +31,9 @@ type UseProductsOptions = {
queryConfig?: QueryConfig<typeof getProductsQueryOptions>;
} & ProductsQueryParams;
export const useProducts = ({ q, page, limit, queryConfig }: UseProductsOptions = {}) => {
export const useProducts = ({ search, page, limit, queryConfig }: UseProductsOptions = {}) => {
return useQuery({
...getProductsQueryOptions({ q, page, limit }),
...getProductsQueryOptions({ search, page, limit }),
...queryConfig,
});
};

View File

@ -21,8 +21,8 @@ export const useUpdateProduct = ({ mutationConfig }: UseUpdateProductOptions = {
return useMutation({
mutationFn: updateProduct,
onSuccess: (data, ...args) => {
queryClient.refetchQueries({
queryKey: getProductsQueryOptions({}).queryKey,
queryClient.invalidateQueries({
queryKey: [getProductsQueryOptions({}).queryKey[0]],
});
onSuccess?.(data, ...args);
},

View File

@ -3,14 +3,14 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { CreateProductModal } from './CreateProductModal';
import { useState } from 'react';
export const ProductListHeader = ({ search, setSearch }: { search: string; setSearch: (v: string) => void }) => {
export const ProductListHeader = ({ search, setSearch }: { search: string | null; setSearch: (v: string | null) => void }) => {
const { t } = useTranslation();
const [isOpenModalCreateProduct, setIsOpenModalCreateProduct] = useState(false);
return (
<>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto">
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search} onChange={(e) => setSearch(e.target.value)} />
<input type="text" className="form-input w-auto" placeholder={t('search')} value={search || ''} onChange={(e) => setSearch(e.target.value || null)} />
</div>
<div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateProduct(true)}>

View File

@ -8,6 +8,8 @@ import { DeleteProductModal } from './DeleteproductModal';
import { Product } from '../types/api';
import { EditProductModal } from './EditProductModal';
import { DetailProductModal } from './DetailProductModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductsList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const productsQuery = useProducts();
const [searchParam, setSearchParam] = useQueryState('search');
const debouncedSearchParam = useDebounce(searchParam, 500);
const productsQuery = useProducts({ search: debouncedSearchParam });
const products = productsQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(products, {
@ -25,10 +30,6 @@ export const ProductsList = () => {
direction: 'desc',
});
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((product) => product.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (product: Product) => {
setSelectedProduct(product);
setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductsList = () => {
return (
<>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5">
<ProductListHeader search={search} setSearch={setSearch} />
<ProductListHeader search={searchParam} setSearch={setSearchParam} />
<div className="datatables pagination-padding px-5">
<DataTable
className="whitespace-nowrap table-hover"
records={filteredRecords}
records={paginatedRecords}
columns={productColumns({ onEdit, onView, onDelete })}
highlightOnHover
minHeight={200}

13
src/hooks/useDebounce.ts Normal file
View File

@ -0,0 +1,13 @@
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay = 500): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
}

View File

@ -1,18 +1,23 @@
import { useEffect, useState } from 'react';
import { DataTableSortStatus } from 'mantine-datatable';
import { sortBy } from 'lodash';
import { parseAsInteger, useQueryState } from 'nuqs';
export function useSortedPaginatedRecords<T>(data: T[] = [], defaultSort: DataTableSortStatus) {
// ✅ Sort status pakai useState biasa (tidak sync ke URL)
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>(defaultSort);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// ✅ Pagination pakai URL
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(10));
const [records, setRecords] = useState<T[]>([]);
useEffect(() => {
const sorted = sortBy(data, sortStatus.columnAccessor);
setRecords(sortStatus.direction === 'desc' ? sorted.reverse() : sorted);
setPage(1);
}, [data, sortStatus]);
setPage(1); // reset ke page 1 kalau sort berubah
}, [data, sortStatus, setPage]);
const paginatedRecords = records.slice((page - 1) * pageSize, page * pageSize);

View File

@ -34,8 +34,13 @@ async function loginFn(dataForm: LoginFormValues): Promise<UsersMeResponse> {
}
async function logoutFn() {
Cookie.clearTokens();
return null;
const promise = new Promise((resolve) => {
setTimeout(() => {
Cookie.clearTokens();
resolve(null);
}, 1000);
});
return promise;
}
const authConfig = {

View File

@ -27,20 +27,31 @@ import { queryClient } from './lib/reactQuery';
import { AuthLoader } from './lib/auth';
import IconLoader from './components/Icon/IconLoader';
// Nuqs
import { NuqsAdapter } from 'nuqs/adapters/react-router/v6';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Suspense>
<Suspense
fallback={
<div className="flex h-screen w-screen items-center justify-center">
<IconLoader className="animate-spin w-16 h-16" />
</div>
}
>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<AuthLoader
renderLoading={() => (
<div className="flex h-screen w-screen items-center justify-center">
<IconLoader className="animate-spin" />
<IconLoader className="animate-spin w-16 h-16" />
</div>
)}
>
<RouterProvider router={router} />
<NuqsAdapter>
<RouterProvider router={router} />
</NuqsAdapter>
</AuthLoader>
</QueryClientProvider>
</Provider>