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", "lodash": "^4.17.21",
"lucide-react": "^0.522.0", "lucide-react": "^0.522.0",
"mantine-datatable": "^1.7.17", "mantine-datatable": "^1.7.17",
"nuqs": "^2.4.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
@ -16067,6 +16068,12 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/mkdirp": {
"version": "0.5.6", "version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@ -16241,6 +16248,39 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "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": { "node_modules/nwsapi": {
"version": "2.2.13", "version": "2.2.13",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.13.tgz",

View File

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

View File

@ -5,9 +5,9 @@ import { IRootState } from '../../store';
import { toggleSidebar } from '../../store/themeConfigSlice'; import { toggleSidebar } from '../../store/themeConfigSlice';
import Footer from './Footer'; import Footer from './Footer';
import Header from './Header'; import Header from './Header';
import Setting from './Setting';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import Portals from '../../components/Portals'; import Portals from '../../components/Portals';
import IconLoader from '../Icon/IconLoader';
const DefaultLayout = ({ children }: PropsWithChildren) => { const DefaultLayout = ({ children }: PropsWithChildren) => {
const themeConfig = useSelector((state: IRootState) => state.themeConfig); const themeConfig = useSelector((state: IRootState) => state.themeConfig);
@ -89,7 +89,13 @@ const DefaultLayout = ({ children }: PropsWithChildren) => {
{/* END TOP NAVBAR */} {/* END TOP NAVBAR */}
{/* BEGIN CONTENT AREA */} {/* 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> <div className={`${themeConfig.animation} p-6 animate__animated`}>{children}</div>
</Suspense> </Suspense>
{/* END CONTENT AREA */} {/* 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, mutationFn: createProductCategory,
onSuccess: (...args) => { onSuccess: (...args) => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: getProductCategoriesQueryOptions().queryKey, queryKey: [getProductCategoriesQueryOptions({}).queryKey[0]],
}); });
onSuccess?.(...args); onSuccess?.(...args);
}, },

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react'; import { useState } from 'react';
import { CreateProductCategoryModal } from './CreateProductCategoryModal'; 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 { t } = useTranslation();
const [isOpenModalCreateCategory, setIsOpenModalCreateCategory] = useState(false); 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="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateCategory(true)}> <button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateCategory(true)}>

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react'; import { useState } from 'react';
import { CreateProductCollectionModal } from './CreateProductCollectionModal'; 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 { t } = useTranslation();
const [isOpenModalCreateCollection, setIsOpenModalCreateCollection] = useState(false); 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="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateCollection(true)}> <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 { DeleteProductCollectionModal } from './DeleteProductCollectionModal';
import { EditProductCollectionModal } from './EditProductCollectionModal'; import { EditProductCollectionModal } from './EditProductCollectionModal';
import { DetailProductCollectionModal } from './DetailProductCollectionModal'; import { DetailProductCollectionModal } from './DetailProductCollectionModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50]; const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductCollectionsList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedCollection, setSelectedCollection] = useState<ProductCollection | null>(null); 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 collections = collectionQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(collections, { const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(collections, {
@ -25,10 +30,6 @@ export const ProductCollectionsList = () => {
direction: 'desc', direction: 'desc',
}); });
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((collection) => collection.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (collection: ProductCollection) => { const onDelete = (collection: ProductCollection) => {
setSelectedCollection(collection); setSelectedCollection(collection);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductCollectionsList = () => {
return ( return (
<> <>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5"> <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"> <div className="datatables pagination-padding px-5">
<DataTable <DataTable
className="whitespace-nowrap table-hover" className="whitespace-nowrap table-hover"
records={filteredRecords} records={paginatedRecords}
columns={productCollectionColumns({ onEdit, onView, onDelete })} columns={productCollectionColumns({ onEdit, onView, onDelete })}
highlightOnHover highlightOnHover
minHeight={200} minHeight={200}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react'; import { useState } from 'react';
import { CreateProductColourModal } from './CreateProductColourModal'; 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 { t } = useTranslation();
const [isOpenModalCreateColour, setIsOpenModalCreateColour] = useState(false); 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="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateColour(true)}> <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 { DeleteProductColourModal } from './DeleteProductColourModal';
import { EditProductColourModal } from './EditProductColourModal'; import { EditProductColourModal } from './EditProductColourModal';
import { DetailProductColourModal } from './DetailProductColourModal'; import { DetailProductColourModal } from './DetailProductColourModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50]; const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductColoursList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedColour, setSelectedColour] = useState<ProductColour | null>(null); 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 colours = colourQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(colours, { const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(colours, {
@ -25,10 +30,6 @@ export const ProductColoursList = () => {
direction: 'desc', direction: 'desc',
}); });
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((colour) => colour.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (colour: ProductColour) => { const onDelete = (colour: ProductColour) => {
setSelectedColour(colour); setSelectedColour(colour);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductColoursList = () => {
return ( return (
<> <>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5"> <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"> <div className="datatables pagination-padding px-5">
<DataTable <DataTable
className="whitespace-nowrap table-hover" className="whitespace-nowrap table-hover"
records={filteredRecords} records={paginatedRecords}
columns={productColourColumns({ onEdit, onView, onDelete })} columns={productColourColumns({ onEdit, onView, onDelete })}
highlightOnHover highlightOnHover
minHeight={200} minHeight={200}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { useState } from 'react'; import { useState } from 'react';
import { CreateProductSizeModal } from './CreateProductSizeModal'; 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 { t } = useTranslation();
const [isOpenModalCreateSize, setIsOpenModalCreateSize] = useState(false); 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="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5">
<div className="ltr:mr-auto rtl:ml-auto"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateSize(true)}> <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 { DeleteProductSizeModal } from './DeleteProductSizeModal';
import { EditProductSizeModal } from './EditProductSizeModal'; import { EditProductSizeModal } from './EditProductSizeModal';
import { DetailProductSizeModal } from './DetailProductSizeModal'; import { DetailProductSizeModal } from './DetailProductSizeModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50]; const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductSizesList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedSize, setSelectedSize] = useState<ProductSize | null>(null); 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 sizes = sizeQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(sizes, { const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(sizes, {
@ -25,10 +30,6 @@ export const ProductSizesList = () => {
direction: 'desc', direction: 'desc',
}); });
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((size) => size.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (size: ProductSize) => { const onDelete = (size: ProductSize) => {
setSelectedSize(size); setSelectedSize(size);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductSizesList = () => {
return ( return (
<> <>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5"> <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"> <div className="datatables pagination-padding px-5">
<DataTable <DataTable
className="whitespace-nowrap table-hover" className="whitespace-nowrap table-hover"
records={filteredRecords} records={paginatedRecords}
columns={productSizeColumns({ onEdit, onView, onDelete })} columns={productSizeColumns({ onEdit, onView, onDelete })}
highlightOnHover highlightOnHover
minHeight={200} minHeight={200}

View File

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

View File

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

View File

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

View File

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

View File

@ -3,14 +3,14 @@ import IconPlus from '../../../components/Icon/IconPlus';
import { CreateProductModal } from './CreateProductModal'; import { CreateProductModal } from './CreateProductModal';
import { useState } from 'react'; 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 { t } = useTranslation();
const [isOpenModalCreateProduct, setIsOpenModalCreateProduct] = useState(false); const [isOpenModalCreateProduct, setIsOpenModalCreateProduct] = useState(false);
return ( return (
<> <>
<div className="mb-4.5 px-5 flex md:items-center md:flex-row flex-col gap-5"> <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"> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button className="btn btn-primary gap-2" onClick={() => setIsOpenModalCreateProduct(true)}> <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 { Product } from '../types/api';
import { EditProductModal } from './EditProductModal'; import { EditProductModal } from './EditProductModal';
import { DetailProductModal } from './DetailProductModal'; import { DetailProductModal } from './DetailProductModal';
import { useQueryState } from 'nuqs';
import { useDebounce } from '../../../hooks/useDebounce';
const PAGE_SIZES = [10, 20, 30, 50]; const PAGE_SIZES = [10, 20, 30, 50];
@ -17,7 +19,10 @@ export const ProductsList = () => {
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); 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 products = productsQuery.data ?? [];
const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(products, { const { sortStatus, setSortStatus, page, setPage, pageSize, setPageSize, paginatedRecords, totalRecords } = useSortedPaginatedRecords(products, {
@ -25,10 +30,6 @@ export const ProductsList = () => {
direction: 'desc', direction: 'desc',
}); });
const [search, setSearch] = useState('');
const filteredRecords = paginatedRecords.filter((product) => product.name.toLowerCase().includes(search.toLowerCase()));
const onDelete = (product: Product) => { const onDelete = (product: Product) => {
setSelectedProduct(product); setSelectedProduct(product);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
@ -47,12 +48,12 @@ export const ProductsList = () => {
return ( return (
<> <>
<div className="panel px-0 border-white-light dark:border-[#1b2e4b] mt-5"> <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"> <div className="datatables pagination-padding px-5">
<DataTable <DataTable
className="whitespace-nowrap table-hover" className="whitespace-nowrap table-hover"
records={filteredRecords} records={paginatedRecords}
columns={productColumns({ onEdit, onView, onDelete })} columns={productColumns({ onEdit, onView, onDelete })}
highlightOnHover highlightOnHover
minHeight={200} 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 { useEffect, useState } from 'react';
import { DataTableSortStatus } from 'mantine-datatable'; import { DataTableSortStatus } from 'mantine-datatable';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { parseAsInteger, useQueryState } from 'nuqs';
export function useSortedPaginatedRecords<T>(data: T[] = [], defaultSort: DataTableSortStatus) { export function useSortedPaginatedRecords<T>(data: T[] = [], defaultSort: DataTableSortStatus) {
// ✅ Sort status pakai useState biasa (tidak sync ke URL)
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>(defaultSort); 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[]>([]); const [records, setRecords] = useState<T[]>([]);
useEffect(() => { useEffect(() => {
const sorted = sortBy(data, sortStatus.columnAccessor); const sorted = sortBy(data, sortStatus.columnAccessor);
setRecords(sortStatus.direction === 'desc' ? sorted.reverse() : sorted); setRecords(sortStatus.direction === 'desc' ? sorted.reverse() : sorted);
setPage(1); setPage(1); // reset ke page 1 kalau sort berubah
}, [data, sortStatus]); }, [data, sortStatus, setPage]);
const paginatedRecords = records.slice((page - 1) * pageSize, page * pageSize); 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() { async function logoutFn() {
Cookie.clearTokens(); const promise = new Promise((resolve) => {
return null; setTimeout(() => {
Cookie.clearTokens();
resolve(null);
}, 1000);
});
return promise;
} }
const authConfig = { const authConfig = {

View File

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