Commit 55241343 authored by Lê Bảo Hồng Đức's avatar Lê Bảo Hồng Đức

[tag]0.1-vcci

parents 6bbee9c4 3e1d1502
Pipeline #52512 failed with stages
in 1 minute and 53 seconds
......@@ -128,6 +128,7 @@ const orvalConfig = async () => {
// Cập nhật khi backend thêm/xóa tag. Bỏ hết filter nếu muốn generate toàn bộ.
tags: [
'Authentication',
'Advertisement',
'Banner',
'Business',
'Category',
......@@ -138,6 +139,7 @@ const orvalConfig = async () => {
'Member',
'NewsletterSubscription',
'PageConfig',
'PasswordResetRequest',
'Permission',
'Position',
'Post',
......
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import {
useInfiniteQuery,
useMutation,
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseInfiniteQueryResult,
DefinedUseQueryResult,
InfiniteData,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
AdvertisementCreate,
AdvertisementUpdate,
DeleteApiV10AdvertisementId200,
GetApiV10AdvertisementId200,
GetApiV10AdvertisementParams,
GetApiV10AdvertisementPublic200,
GetApiV10AdvertisementPublicParams,
PostApiV10Advertisement200,
PutApiV10AdvertisementId200,
ResponseGetAllData
} from '../models';
import { useCustomClient } from '../mutator/custom-client';
import type { ErrorType , BodyType } from '../mutator/custom-client';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Retrieve a single advertisement record by its ID
* @summary Get advertisement by ID
*/
export type getApiV10AdvertisementIdResponse200 = {
data: GetApiV10AdvertisementId200
status: 200
}
export type getApiV10AdvertisementIdResponse404 = {
data: void
status: 404
}
export type getApiV10AdvertisementIdResponseSuccess = (getApiV10AdvertisementIdResponse200) & {
headers: Headers;
};
export type getApiV10AdvertisementIdResponseError = (getApiV10AdvertisementIdResponse404) & {
headers: Headers;
};
export type getApiV10AdvertisementIdResponse = (getApiV10AdvertisementIdResponseSuccess | getApiV10AdvertisementIdResponseError)
export const getGetApiV10AdvertisementIdUrl = (id: string,) => {
return `/api/v1.0/advertisement/${id}`
}
export const getApiV10AdvertisementId = async (id: string, options?: RequestInit): Promise<getApiV10AdvertisementIdResponse> => {
return useCustomClient<getApiV10AdvertisementIdResponse>(getGetApiV10AdvertisementIdUrl(id),
{
...options,
method: 'GET'
}
);}
export const getGetApiV10AdvertisementIdInfiniteQueryKey = (id?: string,) => {
return [
'infinite', `/api/v1.0/advertisement/${id}`
] as const;
}
export const getGetApiV10AdvertisementIdQueryKey = (id?: string,) => {
return [
`/api/v1.0/advertisement/${id}`
] as const;
}
export const getGetApiV10AdvertisementIdInfiniteQueryOptions = <TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>, TError = ErrorType<void>>(id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementIdInfiniteQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10AdvertisementId>>> = ({ signal }) => getApiV10AdvertisementId(id, { signal, ...requestOptions });
return { queryKey, queryFn, enabled: !!(id), retry: 3, retryDelay: 1000, ...queryOptions} as UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementIdInfiniteQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>
export type GetApiV10AdvertisementIdInfiniteQueryError = ErrorType<void>
export function useGetApiV10AdvertisementIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>, TError = ErrorType<void>>(
id: string, options: { query:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementId>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementId>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get advertisement by ID
*/
export function useGetApiV10AdvertisementIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementIdInfiniteQueryOptions(id,options)
const query = useInfiniteQuery(queryOptions, queryClient) as UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get advertisement by ID
*/
export const prefetchGetApiV10AdvertisementIdInfiniteQuery = async <TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
queryClient: QueryClient, id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementIdInfiniteQueryOptions(id,options)
await queryClient.prefetchInfiniteQuery(queryOptions);
return queryClient;
}
export const getGetApiV10AdvertisementIdQueryOptions = <TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementIdQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10AdvertisementId>>> = ({ signal }) => getApiV10AdvertisementId(id, { signal, ...requestOptions });
return { queryKey, queryFn, enabled: !!(id), retry: 3, retryDelay: 1000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementIdQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10AdvertisementId>>>
export type GetApiV10AdvertisementIdQueryError = ErrorType<void>
export function useGetApiV10AdvertisementId<TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementId>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementId<TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementId>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementId<TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get advertisement by ID
*/
export function useGetApiV10AdvertisementId<TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementIdQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get advertisement by ID
*/
export const prefetchGetApiV10AdvertisementIdQuery = async <TData = Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError = ErrorType<void>>(
queryClient: QueryClient, id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementIdQueryOptions(id,options)
await queryClient.prefetchQuery(queryOptions);
return queryClient;
}
/**
* Update a single advertisement record by its ID
* @summary Update advertisement by ID
*/
export type putApiV10AdvertisementIdResponse200 = {
data: PutApiV10AdvertisementId200
status: 200
}
export type putApiV10AdvertisementIdResponse404 = {
data: void
status: 404
}
export type putApiV10AdvertisementIdResponseSuccess = (putApiV10AdvertisementIdResponse200) & {
headers: Headers;
};
export type putApiV10AdvertisementIdResponseError = (putApiV10AdvertisementIdResponse404) & {
headers: Headers;
};
export type putApiV10AdvertisementIdResponse = (putApiV10AdvertisementIdResponseSuccess | putApiV10AdvertisementIdResponseError)
export const getPutApiV10AdvertisementIdUrl = (id: string,) => {
return `/api/v1.0/advertisement/${id}`
}
export const putApiV10AdvertisementId = async (id: string,
advertisementUpdate: AdvertisementUpdate, options?: RequestInit): Promise<putApiV10AdvertisementIdResponse> => {
return useCustomClient<putApiV10AdvertisementIdResponse>(getPutApiV10AdvertisementIdUrl(id),
{
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
advertisementUpdate,)
}
);}
export const getPutApiV10AdvertisementIdMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof putApiV10AdvertisementId>>, TError,{id: string;data: BodyType<AdvertisementUpdate>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof putApiV10AdvertisementId>>, TError,{id: string;data: BodyType<AdvertisementUpdate>}, TContext> => {
const mutationKey = ['putApiV10AdvertisementId'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof putApiV10AdvertisementId>>, {id: string;data: BodyType<AdvertisementUpdate>}> = (props) => {
const {id,data} = props ?? {};
return putApiV10AdvertisementId(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PutApiV10AdvertisementIdMutationResult = NonNullable<Awaited<ReturnType<typeof putApiV10AdvertisementId>>>
export type PutApiV10AdvertisementIdMutationBody = BodyType<AdvertisementUpdate>
export type PutApiV10AdvertisementIdMutationError = ErrorType<void>
/**
* @summary Update advertisement by ID
*/
export const usePutApiV10AdvertisementId = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof putApiV10AdvertisementId>>, TError,{id: string;data: BodyType<AdvertisementUpdate>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof putApiV10AdvertisementId>>,
TError,
{id: string;data: BodyType<AdvertisementUpdate>},
TContext
> => {
const mutationOptions = getPutApiV10AdvertisementIdMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Delete a single advertisement record by its ID
* @summary Delete advertisement by ID
*/
export type deleteApiV10AdvertisementIdResponse200 = {
data: DeleteApiV10AdvertisementId200
status: 200
}
export type deleteApiV10AdvertisementIdResponse404 = {
data: void
status: 404
}
export type deleteApiV10AdvertisementIdResponseSuccess = (deleteApiV10AdvertisementIdResponse200) & {
headers: Headers;
};
export type deleteApiV10AdvertisementIdResponseError = (deleteApiV10AdvertisementIdResponse404) & {
headers: Headers;
};
export type deleteApiV10AdvertisementIdResponse = (deleteApiV10AdvertisementIdResponseSuccess | deleteApiV10AdvertisementIdResponseError)
export const getDeleteApiV10AdvertisementIdUrl = (id: string,) => {
return `/api/v1.0/advertisement/${id}`
}
export const deleteApiV10AdvertisementId = async (id: string, options?: RequestInit): Promise<deleteApiV10AdvertisementIdResponse> => {
return useCustomClient<deleteApiV10AdvertisementIdResponse>(getDeleteApiV10AdvertisementIdUrl(id),
{
...options,
method: 'DELETE'
}
);}
export const getDeleteApiV10AdvertisementIdMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>, TError,{id: string}, TContext> => {
const mutationKey = ['deleteApiV10AdvertisementId'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>, {id: string}> = (props) => {
const {id} = props ?? {};
return deleteApiV10AdvertisementId(id,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteApiV10AdvertisementIdMutationResult = NonNullable<Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>>
export type DeleteApiV10AdvertisementIdMutationError = ErrorType<void>
/**
* @summary Delete advertisement by ID
*/
export const useDeleteApiV10AdvertisementId = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteApiV10AdvertisementId>>,
TError,
{id: string},
TContext
> => {
const mutationOptions = getDeleteApiV10AdvertisementIdMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve active advertisements filtered by type, sorted by sort_order. No auth required. Includes file info (path, mime).
* @summary Get active advertisements (public)
*/
export type getApiV10AdvertisementPublicResponse200 = {
data: GetApiV10AdvertisementPublic200
status: 200
}
export type getApiV10AdvertisementPublicResponseSuccess = (getApiV10AdvertisementPublicResponse200) & {
headers: Headers;
};
;
export type getApiV10AdvertisementPublicResponse = (getApiV10AdvertisementPublicResponseSuccess)
export const getGetApiV10AdvertisementPublicUrl = (params?: GetApiV10AdvertisementPublicParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `/api/v1.0/advertisement/public?${stringifiedParams}` : `/api/v1.0/advertisement/public`
}
export const getApiV10AdvertisementPublic = async (params?: GetApiV10AdvertisementPublicParams, options?: RequestInit): Promise<getApiV10AdvertisementPublicResponse> => {
return useCustomClient<getApiV10AdvertisementPublicResponse>(getGetApiV10AdvertisementPublicUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetApiV10AdvertisementPublicInfiniteQueryKey = (params?: GetApiV10AdvertisementPublicParams,) => {
return [
'infinite', `/api/v1.0/advertisement/public`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10AdvertisementPublicQueryKey = (params?: GetApiV10AdvertisementPublicParams,) => {
return [
`/api/v1.0/advertisement/public`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10AdvertisementPublicInfiniteQueryOptions = <TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>, TError = ErrorType<unknown>>(params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementPublicInfiniteQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>> = ({ signal }) => getApiV10AdvertisementPublic(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementPublicInfiniteQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>
export type GetApiV10AdvertisementPublicInfiniteQueryError = ErrorType<unknown>
export function useGetApiV10AdvertisementPublicInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>, TError = ErrorType<unknown>>(
params: undefined | GetApiV10AdvertisementPublicParams, options: { query:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementPublicInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementPublicInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get active advertisements (public)
*/
export function useGetApiV10AdvertisementPublicInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementPublicInfiniteQueryOptions(params,options)
const query = useInfiniteQuery(queryOptions, queryClient) as UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get active advertisements (public)
*/
export const prefetchGetApiV10AdvertisementPublicInfiniteQuery = async <TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
queryClient: QueryClient, params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementPublicInfiniteQueryOptions(params,options)
await queryClient.prefetchInfiniteQuery(queryOptions);
return queryClient;
}
export const getGetApiV10AdvertisementPublicQueryOptions = <TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementPublicQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>> = ({ signal }) => getApiV10AdvertisementPublic(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementPublicQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>>
export type GetApiV10AdvertisementPublicQueryError = ErrorType<unknown>
export function useGetApiV10AdvertisementPublic<TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
params: undefined | GetApiV10AdvertisementPublicParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementPublic<TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>,
TError,
Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementPublic<TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get active advertisements (public)
*/
export function useGetApiV10AdvertisementPublic<TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementPublicQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get active advertisements (public)
*/
export const prefetchGetApiV10AdvertisementPublicQuery = async <TData = Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError = ErrorType<unknown>>(
queryClient: QueryClient, params?: GetApiV10AdvertisementPublicParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10AdvertisementPublic>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementPublicQueryOptions(params,options)
await queryClient.prefetchQuery(queryOptions);
return queryClient;
}
/**
* Retrieve a list of advertisements with pagination, filtering and sorting
* @summary Get all advertisements
*/
export type getApiV10AdvertisementResponse200 = {
data: ResponseGetAllData
status: 200
}
export type getApiV10AdvertisementResponseSuccess = (getApiV10AdvertisementResponse200) & {
headers: Headers;
};
;
export type getApiV10AdvertisementResponse = (getApiV10AdvertisementResponseSuccess)
export const getGetApiV10AdvertisementUrl = (params?: GetApiV10AdvertisementParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `/api/v1.0/advertisement?${stringifiedParams}` : `/api/v1.0/advertisement`
}
export const getApiV10Advertisement = async (params?: GetApiV10AdvertisementParams, options?: RequestInit): Promise<getApiV10AdvertisementResponse> => {
return useCustomClient<getApiV10AdvertisementResponse>(getGetApiV10AdvertisementUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetApiV10AdvertisementInfiniteQueryKey = (params?: GetApiV10AdvertisementParams,) => {
return [
'infinite', `/api/v1.0/advertisement`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10AdvertisementQueryKey = (params?: GetApiV10AdvertisementParams,) => {
return [
`/api/v1.0/advertisement`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10AdvertisementInfiniteQueryOptions = <TData = InfiniteData<Awaited<ReturnType<typeof getApiV10Advertisement>>>, TError = ErrorType<unknown>>(params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementInfiniteQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10Advertisement>>> = ({ signal }) => getApiV10Advertisement(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementInfiniteQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10Advertisement>>>
export type GetApiV10AdvertisementInfiniteQueryError = ErrorType<unknown>
export function useGetApiV10AdvertisementInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10Advertisement>>>, TError = ErrorType<unknown>>(
params: undefined | GetApiV10AdvertisementParams, options: { query:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10Advertisement>>,
TError,
Awaited<ReturnType<typeof getApiV10Advertisement>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10Advertisement>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10Advertisement>>,
TError,
Awaited<ReturnType<typeof getApiV10Advertisement>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10AdvertisementInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10Advertisement>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get all advertisements
*/
export function useGetApiV10AdvertisementInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10Advertisement>>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementInfiniteQueryOptions(params,options)
const query = useInfiniteQuery(queryOptions, queryClient) as UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get all advertisements
*/
export const prefetchGetApiV10AdvertisementInfiniteQuery = async <TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
queryClient: QueryClient, params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementInfiniteQueryOptions(params,options)
await queryClient.prefetchInfiniteQuery(queryOptions);
return queryClient;
}
export const getGetApiV10AdvertisementQueryOptions = <TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10AdvertisementQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10Advertisement>>> = ({ signal }) => getApiV10Advertisement(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10AdvertisementQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10Advertisement>>>
export type GetApiV10AdvertisementQueryError = ErrorType<unknown>
export function useGetApiV10Advertisement<TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
params: undefined | GetApiV10AdvertisementParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10Advertisement>>,
TError,
Awaited<ReturnType<typeof getApiV10Advertisement>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10Advertisement<TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10Advertisement>>,
TError,
Awaited<ReturnType<typeof getApiV10Advertisement>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10Advertisement<TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get all advertisements
*/
export function useGetApiV10Advertisement<TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10AdvertisementQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get all advertisements
*/
export const prefetchGetApiV10AdvertisementQuery = async <TData = Awaited<ReturnType<typeof getApiV10Advertisement>>, TError = ErrorType<unknown>>(
queryClient: QueryClient, params?: GetApiV10AdvertisementParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10Advertisement>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10AdvertisementQueryOptions(params,options)
await queryClient.prefetchQuery(queryOptions);
return queryClient;
}
/**
* Create a new advertisement record
* @summary Create an advertisement
*/
export type postApiV10AdvertisementResponse200 = {
data: PostApiV10Advertisement200
status: 200
}
export type postApiV10AdvertisementResponseSuccess = (postApiV10AdvertisementResponse200) & {
headers: Headers;
};
;
export type postApiV10AdvertisementResponse = (postApiV10AdvertisementResponseSuccess)
export const getPostApiV10AdvertisementUrl = () => {
return `/api/v1.0/advertisement`
}
export const postApiV10Advertisement = async (advertisementCreate: AdvertisementCreate, options?: RequestInit): Promise<postApiV10AdvertisementResponse> => {
return useCustomClient<postApiV10AdvertisementResponse>(getPostApiV10AdvertisementUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
advertisementCreate,)
}
);}
export const getPostApiV10AdvertisementMutationOptions = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10Advertisement>>, TError,{data: BodyType<AdvertisementCreate>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10Advertisement>>, TError,{data: BodyType<AdvertisementCreate>}, TContext> => {
const mutationKey = ['postApiV10Advertisement'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10Advertisement>>, {data: BodyType<AdvertisementCreate>}> = (props) => {
const {data} = props ?? {};
return postApiV10Advertisement(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10AdvertisementMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10Advertisement>>>
export type PostApiV10AdvertisementMutationBody = BodyType<AdvertisementCreate>
export type PostApiV10AdvertisementMutationError = ErrorType<unknown>
/**
* @summary Create an advertisement
*/
export const usePostApiV10Advertisement = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10Advertisement>>, TError,{data: BodyType<AdvertisementCreate>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10Advertisement>>,
TError,
{data: BodyType<AdvertisementCreate>},
TContext
> => {
const mutationOptions = getPostApiV10AdvertisementMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
\ No newline at end of file
......@@ -37,6 +37,7 @@ import type {
GetApiV10AuthMe200,
GetApiV10AuthProfile200,
LoginRequest,
PostApiV10AuthForgotPasswordRequestBody,
PostApiV10AuthForgotPasswordVerifyOtp200,
PostApiV10AuthLogin200,
PostApiV10AuthLogin423,
......@@ -1163,4 +1164,103 @@ export const usePostApiV10AuthForgotPasswordReset = <TError = ErrorType<BadReque
return useMutation(mutationOptions, queryClient);
}
/**
* User quên mật khẩu gửi yêu cầu reset về admin.
Admin sẽ xem và xử lý (reset password) thay vì gửi OTP qua email.
Anti-enumeration: luôn trả 200 kể cả khi email không tồn tại.
* @summary Forgot password - submit reset request to admin
*/
export type postApiV10AuthForgotPasswordRequestResponse200 = {
data: void
status: 200
}
export type postApiV10AuthForgotPasswordRequestResponse400 = {
data: void
status: 400
}
export type postApiV10AuthForgotPasswordRequestResponse429 = {
data: void
status: 429
}
export type postApiV10AuthForgotPasswordRequestResponseSuccess = (postApiV10AuthForgotPasswordRequestResponse200) & {
headers: Headers;
};
export type postApiV10AuthForgotPasswordRequestResponseError = (postApiV10AuthForgotPasswordRequestResponse400 | postApiV10AuthForgotPasswordRequestResponse429) & {
headers: Headers;
};
export type postApiV10AuthForgotPasswordRequestResponse = (postApiV10AuthForgotPasswordRequestResponseSuccess | postApiV10AuthForgotPasswordRequestResponseError)
export const getPostApiV10AuthForgotPasswordRequestUrl = () => {
return `/api/v1.0/auth/forgot-password/request`
}
export const postApiV10AuthForgotPasswordRequest = async (postApiV10AuthForgotPasswordRequestBody: PostApiV10AuthForgotPasswordRequestBody, options?: RequestInit): Promise<postApiV10AuthForgotPasswordRequestResponse> => {
return useCustomClient<postApiV10AuthForgotPasswordRequestResponse>(getPostApiV10AuthForgotPasswordRequestUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10AuthForgotPasswordRequestBody,)
}
);}
export const getPostApiV10AuthForgotPasswordRequestMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext> => {
const mutationKey = ['postApiV10AuthForgotPasswordRequest'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, {data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}> = (props) => {
const {data} = props ?? {};
return postApiV10AuthForgotPasswordRequest(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10AuthForgotPasswordRequestMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>>
export type PostApiV10AuthForgotPasswordRequestMutationBody = BodyType<PostApiV10AuthForgotPasswordRequestBody>
export type PostApiV10AuthForgotPasswordRequestMutationError = ErrorType<void>
/**
* @summary Forgot password - submit reset request to admin
*/
export const usePostApiV10AuthForgotPasswordRequest = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>, TError,{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10AuthForgotPasswordRequest>>,
TError,
{data: BodyType<PostApiV10AuthForgotPasswordRequestBody>},
TContext
> => {
const mutationOptions = getPostApiV10AuthForgotPasswordRequestMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
\ No newline at end of file
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import {
useInfiniteQuery,
useMutation,
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseInfiniteQueryResult,
DefinedUseQueryResult,
InfiniteData,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
GetApiV10PasswordResetRequestParams,
PostApiV10PasswordResetRequestIdRejectBody,
PostApiV10PasswordResetRequestIdResolveBody
} from '../models';
import { useCustomClient } from '../mutator/custom-client';
import type { ErrorType , BodyType } from '../mutator/custom-client';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Lấy danh sách yêu cầu reset mật khẩu. Yêu cầu quyền users:write.
* @summary List password reset requests (admin)
*/
export type getApiV10PasswordResetRequestResponse200 = {
data: void
status: 200
}
export type getApiV10PasswordResetRequestResponse401 = {
data: void
status: 401
}
export type getApiV10PasswordResetRequestResponse403 = {
data: void
status: 403
}
export type getApiV10PasswordResetRequestResponseSuccess = (getApiV10PasswordResetRequestResponse200) & {
headers: Headers;
};
export type getApiV10PasswordResetRequestResponseError = (getApiV10PasswordResetRequestResponse401 | getApiV10PasswordResetRequestResponse403) & {
headers: Headers;
};
export type getApiV10PasswordResetRequestResponse = (getApiV10PasswordResetRequestResponseSuccess | getApiV10PasswordResetRequestResponseError)
export const getGetApiV10PasswordResetRequestUrl = (params?: GetApiV10PasswordResetRequestParams,) => {
const normalizedParams = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
if (value !== undefined) {
normalizedParams.append(key, value === null ? 'null' : value.toString())
}
});
const stringifiedParams = normalizedParams.toString();
return stringifiedParams.length > 0 ? `/api/v1.0/password-reset-request?${stringifiedParams}` : `/api/v1.0/password-reset-request`
}
export const getApiV10PasswordResetRequest = async (params?: GetApiV10PasswordResetRequestParams, options?: RequestInit): Promise<getApiV10PasswordResetRequestResponse> => {
return useCustomClient<getApiV10PasswordResetRequestResponse>(getGetApiV10PasswordResetRequestUrl(params),
{
...options,
method: 'GET'
}
);}
export const getGetApiV10PasswordResetRequestInfiniteQueryKey = (params?: GetApiV10PasswordResetRequestParams,) => {
return [
'infinite', `/api/v1.0/password-reset-request`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10PasswordResetRequestQueryKey = (params?: GetApiV10PasswordResetRequestParams,) => {
return [
`/api/v1.0/password-reset-request`, ...(params ? [params]: [])
] as const;
}
export const getGetApiV10PasswordResetRequestInfiniteQueryOptions = <TData = InfiniteData<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>, TError = ErrorType<void>>(params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10PasswordResetRequestInfiniteQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>> = ({ signal }) => getApiV10PasswordResetRequest(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10PasswordResetRequestInfiniteQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>
export type GetApiV10PasswordResetRequestInfiniteQueryError = ErrorType<void>
export function useGetApiV10PasswordResetRequestInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>, TError = ErrorType<void>>(
params: undefined | GetApiV10PasswordResetRequestParams, options: { query:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>,
TError,
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10PasswordResetRequestInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>,
TError,
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10PasswordResetRequestInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary List password reset requests (admin)
*/
export function useGetApiV10PasswordResetRequestInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10PasswordResetRequestInfiniteQueryOptions(params,options)
const query = useInfiniteQuery(queryOptions, queryClient) as UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary List password reset requests (admin)
*/
export const prefetchGetApiV10PasswordResetRequestInfiniteQuery = async <TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
queryClient: QueryClient, params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10PasswordResetRequestInfiniteQueryOptions(params,options)
await queryClient.prefetchInfiniteQuery(queryOptions);
return queryClient;
}
export const getGetApiV10PasswordResetRequestQueryOptions = <TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10PasswordResetRequestQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>> = ({ signal }) => getApiV10PasswordResetRequest(params, { signal, ...requestOptions });
return { queryKey, queryFn, retry: 3, retryDelay: 1000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10PasswordResetRequestQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>>
export type GetApiV10PasswordResetRequestQueryError = ErrorType<void>
export function useGetApiV10PasswordResetRequest<TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
params: undefined | GetApiV10PasswordResetRequestParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>,
TError,
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10PasswordResetRequest<TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>,
TError,
Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10PasswordResetRequest<TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary List password reset requests (admin)
*/
export function useGetApiV10PasswordResetRequest<TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10PasswordResetRequestQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary List password reset requests (admin)
*/
export const prefetchGetApiV10PasswordResetRequestQuery = async <TData = Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError = ErrorType<void>>(
queryClient: QueryClient, params?: GetApiV10PasswordResetRequestParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10PasswordResetRequest>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10PasswordResetRequestQueryOptions(params,options)
await queryClient.prefetchQuery(queryOptions);
return queryClient;
}
/**
* Admin xử lý yêu cầu reset mật khẩu.
- Reset password cho user về mật khẩu mặc định (vcci@2026) hoặc mật khẩu tùy chỉnh.
- Set must_change_password=true để user phải đổi mật khẩu khi login.
- Đánh dấu request là RESOLVED.
* @summary Resolve a password reset request (reset password)
*/
export type postApiV10PasswordResetRequestIdResolveResponse200 = {
data: void
status: 200
}
export type postApiV10PasswordResetRequestIdResolveResponse404 = {
data: void
status: 404
}
export type postApiV10PasswordResetRequestIdResolveResponseSuccess = (postApiV10PasswordResetRequestIdResolveResponse200) & {
headers: Headers;
};
export type postApiV10PasswordResetRequestIdResolveResponseError = (postApiV10PasswordResetRequestIdResolveResponse404) & {
headers: Headers;
};
export type postApiV10PasswordResetRequestIdResolveResponse = (postApiV10PasswordResetRequestIdResolveResponseSuccess | postApiV10PasswordResetRequestIdResolveResponseError)
export const getPostApiV10PasswordResetRequestIdResolveUrl = (id: string,) => {
return `/api/v1.0/password-reset-request/${id}/resolve`
}
export const postApiV10PasswordResetRequestIdResolve = async (id: string,
postApiV10PasswordResetRequestIdResolveBody: PostApiV10PasswordResetRequestIdResolveBody, options?: RequestInit): Promise<postApiV10PasswordResetRequestIdResolveResponse> => {
return useCustomClient<postApiV10PasswordResetRequestIdResolveResponse>(getPostApiV10PasswordResetRequestIdResolveUrl(id),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10PasswordResetRequestIdResolveBody,)
}
);}
export const getPostApiV10PasswordResetRequestIdResolveMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdResolveBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdResolveBody>}, TContext> => {
const mutationKey = ['postApiV10PasswordResetRequestIdResolve'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>, {id: string;data: BodyType<PostApiV10PasswordResetRequestIdResolveBody>}> = (props) => {
const {id,data} = props ?? {};
return postApiV10PasswordResetRequestIdResolve(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10PasswordResetRequestIdResolveMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>>
export type PostApiV10PasswordResetRequestIdResolveMutationBody = BodyType<PostApiV10PasswordResetRequestIdResolveBody>
export type PostApiV10PasswordResetRequestIdResolveMutationError = ErrorType<void>
/**
* @summary Resolve a password reset request (reset password)
*/
export const usePostApiV10PasswordResetRequestIdResolve = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdResolveBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdResolve>>,
TError,
{id: string;data: BodyType<PostApiV10PasswordResetRequestIdResolveBody>},
TContext
> => {
const mutationOptions = getPostApiV10PasswordResetRequestIdResolveMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Admin từ chối yêu cầu reset mật khẩu.
* @summary Reject a password reset request
*/
export type postApiV10PasswordResetRequestIdRejectResponse200 = {
data: void
status: 200
}
export type postApiV10PasswordResetRequestIdRejectResponse404 = {
data: void
status: 404
}
export type postApiV10PasswordResetRequestIdRejectResponseSuccess = (postApiV10PasswordResetRequestIdRejectResponse200) & {
headers: Headers;
};
export type postApiV10PasswordResetRequestIdRejectResponseError = (postApiV10PasswordResetRequestIdRejectResponse404) & {
headers: Headers;
};
export type postApiV10PasswordResetRequestIdRejectResponse = (postApiV10PasswordResetRequestIdRejectResponseSuccess | postApiV10PasswordResetRequestIdRejectResponseError)
export const getPostApiV10PasswordResetRequestIdRejectUrl = (id: string,) => {
return `/api/v1.0/password-reset-request/${id}/reject`
}
export const postApiV10PasswordResetRequestIdReject = async (id: string,
postApiV10PasswordResetRequestIdRejectBody: PostApiV10PasswordResetRequestIdRejectBody, options?: RequestInit): Promise<postApiV10PasswordResetRequestIdRejectResponse> => {
return useCustomClient<postApiV10PasswordResetRequestIdRejectResponse>(getPostApiV10PasswordResetRequestIdRejectUrl(id),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10PasswordResetRequestIdRejectBody,)
}
);}
export const getPostApiV10PasswordResetRequestIdRejectMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdRejectBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdRejectBody>}, TContext> => {
const mutationKey = ['postApiV10PasswordResetRequestIdReject'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>, {id: string;data: BodyType<PostApiV10PasswordResetRequestIdRejectBody>}> = (props) => {
const {id,data} = props ?? {};
return postApiV10PasswordResetRequestIdReject(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10PasswordResetRequestIdRejectMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>>
export type PostApiV10PasswordResetRequestIdRejectMutationBody = BodyType<PostApiV10PasswordResetRequestIdRejectBody>
export type PostApiV10PasswordResetRequestIdRejectMutationError = ErrorType<void>
/**
* @summary Reject a password reset request
*/
export const usePostApiV10PasswordResetRequestIdReject = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>, TError,{id: string;data: BodyType<PostApiV10PasswordResetRequestIdRejectBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10PasswordResetRequestIdReject>>,
TError,
{id: string;data: BodyType<PostApiV10PasswordResetRequestIdRejectBody>},
TContext
> => {
const mutationOptions = getPostApiV10PasswordResetRequestIdRejectMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
\ No newline at end of file
......@@ -374,7 +374,90 @@ export const usePutApiV10PermissionId = <TError = ErrorType<void>,
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve a list of permissions with pagination, filtering and sorting
* Sync permissions từ file config vào database. Chỉ system_admin mới có quyền.
* @summary Sync permissions from config
*/
export type postApiV10PermissionSyncResponse200 = {
data: void
status: 200
}
export type postApiV10PermissionSyncResponseSuccess = (postApiV10PermissionSyncResponse200) & {
headers: Headers;
};
;
export type postApiV10PermissionSyncResponse = (postApiV10PermissionSyncResponseSuccess)
export const getPostApiV10PermissionSyncUrl = () => {
return `/api/v1.0/permission/sync`
}
export const postApiV10PermissionSync = async ( options?: RequestInit): Promise<postApiV10PermissionSyncResponse> => {
return useCustomClient<postApiV10PermissionSyncResponse>(getPostApiV10PermissionSyncUrl(),
{
...options,
method: 'POST'
}
);}
export const getPostApiV10PermissionSyncMutationOptions = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext> => {
const mutationKey = ['postApiV10PermissionSync'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10PermissionSync>>, void> = () => {
return postApiV10PermissionSync(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10PermissionSyncMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10PermissionSync>>>
export type PostApiV10PermissionSyncMutationError = ErrorType<unknown>
/**
* @summary Sync permissions from config
*/
export const usePostApiV10PermissionSync = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10PermissionSync>>, TError,void, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10PermissionSync>>,
TError,
void,
TContext
> => {
const mutationOptions = getPostApiV10PermissionSyncMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve a list of permissions with pagination, filtering and sorting. Also returns structured permissions grouped by resource.
* @summary Get all permissions
*/
export type getApiV10PermissionResponse200 = {
......
......@@ -32,8 +32,8 @@ import type {
import type {
DeleteApiV10RoleBulk200,
GetApiV10RoleParams,
PostApiV10RoleBody,
PostApiV10RoleBulk200,
PutApiV10RoleId200,
ResponseGetAllData,
RoleBulkCreate,
RoleBulkDelete,
......@@ -49,12 +49,233 @@ type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* Retrieve a single role by its ID, including users assigned to this role
* @summary Get role by ID
*/
export type getApiV10RoleIdResponse200 = {
data: void
status: 200
}
export type getApiV10RoleIdResponse404 = {
data: void
status: 404
}
export type getApiV10RoleIdResponseSuccess = (getApiV10RoleIdResponse200) & {
headers: Headers;
};
export type getApiV10RoleIdResponseError = (getApiV10RoleIdResponse404) & {
headers: Headers;
};
export type getApiV10RoleIdResponse = (getApiV10RoleIdResponseSuccess | getApiV10RoleIdResponseError)
export const getGetApiV10RoleIdUrl = (id: string,) => {
return `/api/v1.0/role/${id}`
}
export const getApiV10RoleId = async (id: string, options?: RequestInit): Promise<getApiV10RoleIdResponse> => {
return useCustomClient<getApiV10RoleIdResponse>(getGetApiV10RoleIdUrl(id),
{
...options,
method: 'GET'
}
);}
export const getGetApiV10RoleIdInfiniteQueryKey = (id?: string,) => {
return [
'infinite', `/api/v1.0/role/${id}`
] as const;
}
export const getGetApiV10RoleIdQueryKey = (id?: string,) => {
return [
`/api/v1.0/role/${id}`
] as const;
}
export const getGetApiV10RoleIdInfiniteQueryOptions = <TData = InfiniteData<Awaited<ReturnType<typeof getApiV10RoleId>>>, TError = ErrorType<void>>(id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10RoleIdInfiniteQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10RoleId>>> = ({ signal }) => getApiV10RoleId(id, { signal, ...requestOptions });
return { queryKey, queryFn, enabled: !!(id), retry: 3, retryDelay: 1000, ...queryOptions} as UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10RoleIdInfiniteQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10RoleId>>>
export type GetApiV10RoleIdInfiniteQueryError = ErrorType<void>
export function useGetApiV10RoleIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10RoleId>>>, TError = ErrorType<void>>(
id: string, options: { query:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10RoleId>>,
TError,
Awaited<ReturnType<typeof getApiV10RoleId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10RoleIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10RoleId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10RoleId>>,
TError,
Awaited<ReturnType<typeof getApiV10RoleId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10RoleIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10RoleId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get role by ID
*/
export function useGetApiV10RoleIdInfinite<TData = InfiniteData<Awaited<ReturnType<typeof getApiV10RoleId>>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10RoleIdInfiniteQueryOptions(id,options)
const query = useInfiniteQuery(queryOptions, queryClient) as UseInfiniteQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get role by ID
*/
export const prefetchGetApiV10RoleIdInfiniteQuery = async <TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
queryClient: QueryClient, id: string, options?: { query?:Partial<UseInfiniteQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10RoleIdInfiniteQueryOptions(id,options)
await queryClient.prefetchInfiniteQuery(queryOptions);
return queryClient;
}
export const getGetApiV10RoleIdQueryOptions = <TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetApiV10RoleIdQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getApiV10RoleId>>> = ({ signal }) => getApiV10RoleId(id, { signal, ...requestOptions });
return { queryKey, queryFn, enabled: !!(id), retry: 3, retryDelay: 1000, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetApiV10RoleIdQueryResult = NonNullable<Awaited<ReturnType<typeof getApiV10RoleId>>>
export type GetApiV10RoleIdQueryError = ErrorType<void>
export function useGetApiV10RoleId<TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10RoleId>>,
TError,
Awaited<ReturnType<typeof getApiV10RoleId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10RoleId<TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getApiV10RoleId>>,
TError,
Awaited<ReturnType<typeof getApiV10RoleId>>
> , 'initialData'
>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetApiV10RoleId<TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get role by ID
*/
export function useGetApiV10RoleId<TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetApiV10RoleIdQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary Get role by ID
*/
export const prefetchGetApiV10RoleIdQuery = async <TData = Awaited<ReturnType<typeof getApiV10RoleId>>, TError = ErrorType<void>>(
queryClient: QueryClient, id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getApiV10RoleId>>, TError, TData>>, request?: SecondParameter<typeof useCustomClient>}
): Promise<QueryClient> => {
const queryOptions = getGetApiV10RoleIdQueryOptions(id,options)
await queryClient.prefetchQuery(queryOptions);
return queryClient;
}
/**
* Update a single role record by its ID
* @summary Update role by ID
*/
export type putApiV10RoleIdResponse200 = {
data: PutApiV10RoleId200
data: void
status: 200
}
......@@ -147,6 +368,101 @@ export const usePutApiV10RoleId = <TError = ErrorType<void>,
return useMutation(mutationOptions, queryClient);
}
/**
* Delete a single role by its ID. Cannot delete system roles (system_admin).
* @summary Delete role by ID
*/
export type deleteApiV10RoleIdResponse200 = {
data: void
status: 200
}
export type deleteApiV10RoleIdResponse400 = {
data: void
status: 400
}
export type deleteApiV10RoleIdResponse404 = {
data: void
status: 404
}
export type deleteApiV10RoleIdResponseSuccess = (deleteApiV10RoleIdResponse200) & {
headers: Headers;
};
export type deleteApiV10RoleIdResponseError = (deleteApiV10RoleIdResponse400 | deleteApiV10RoleIdResponse404) & {
headers: Headers;
};
export type deleteApiV10RoleIdResponse = (deleteApiV10RoleIdResponseSuccess | deleteApiV10RoleIdResponseError)
export const getDeleteApiV10RoleIdUrl = (id: string,) => {
return `/api/v1.0/role/${id}`
}
export const deleteApiV10RoleId = async (id: string, options?: RequestInit): Promise<deleteApiV10RoleIdResponse> => {
return useCustomClient<deleteApiV10RoleIdResponse>(getDeleteApiV10RoleIdUrl(id),
{
...options,
method: 'DELETE'
}
);}
export const getDeleteApiV10RoleIdMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10RoleId>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10RoleId>>, TError,{id: string}, TContext> => {
const mutationKey = ['deleteApiV10RoleId'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteApiV10RoleId>>, {id: string}> = (props) => {
const {id} = props ?? {};
return deleteApiV10RoleId(id,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteApiV10RoleIdMutationResult = NonNullable<Awaited<ReturnType<typeof deleteApiV10RoleId>>>
export type DeleteApiV10RoleIdMutationError = ErrorType<void>
/**
* @summary Delete role by ID
*/
export const useDeleteApiV10RoleId = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10RoleId>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteApiV10RoleId>>,
TError,
{id: string},
TContext
> => {
const mutationOptions = getDeleteApiV10RoleIdMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Retrieve a list of roles with pagination, filtering and sorting
* @summary Get all roles
*/
......@@ -368,6 +684,90 @@ export const prefetchGetApiV10RoleQuery = async <TData = Awaited<ReturnType<type
/**
* Create a new role with permissions
* @summary Create a new role
*/
export type postApiV10RoleResponse200 = {
data: void
status: 200
}
export type postApiV10RoleResponseSuccess = (postApiV10RoleResponse200) & {
headers: Headers;
};
;
export type postApiV10RoleResponse = (postApiV10RoleResponseSuccess)
export const getPostApiV10RoleUrl = () => {
return `/api/v1.0/role`
}
export const postApiV10Role = async (postApiV10RoleBody: PostApiV10RoleBody, options?: RequestInit): Promise<postApiV10RoleResponse> => {
return useCustomClient<postApiV10RoleResponse>(getPostApiV10RoleUrl(),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10RoleBody,)
}
);}
export const getPostApiV10RoleMutationOptions = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10Role>>, TError,{data: BodyType<PostApiV10RoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10Role>>, TError,{data: BodyType<PostApiV10RoleBody>}, TContext> => {
const mutationKey = ['postApiV10Role'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10Role>>, {data: BodyType<PostApiV10RoleBody>}> = (props) => {
const {data} = props ?? {};
return postApiV10Role(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10RoleMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10Role>>>
export type PostApiV10RoleMutationBody = BodyType<PostApiV10RoleBody>
export type PostApiV10RoleMutationError = ErrorType<unknown>
/**
* @summary Create a new role
*/
export const usePostApiV10Role = <TError = ErrorType<unknown>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10Role>>, TError,{data: BodyType<PostApiV10RoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10Role>>,
TError,
{data: BodyType<PostApiV10RoleBody>},
TContext
> => {
const mutationOptions = getPostApiV10RoleMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Create multiple role records at once
* @summary Bulk create roles
*/
......
......@@ -30,7 +30,10 @@ import type {
} from '@tanstack/react-query';
import type {
DeleteApiV10UserIdRoleBody,
GetApiV10UserParams,
PatchApiV10UserIdStatusBody,
PostApiV10UserIdRoleBody,
UserChangePassword,
UserCreate,
UserUpdate
......@@ -865,4 +868,380 @@ export const usePutApiV10UserChangePassword = <TError = ErrorType<void>,
return useMutation(mutationOptions, queryClient);
}
/**
* Bật hoặc tắt trạng thái tài khoản user. Chỉ users:write mới có quyền.
* @summary Toggle user status (enable/disable)
*/
export type patchApiV10UserIdStatusResponse200 = {
data: void
status: 200
}
export type patchApiV10UserIdStatusResponse403 = {
data: void
status: 403
}
export type patchApiV10UserIdStatusResponse404 = {
data: void
status: 404
}
export type patchApiV10UserIdStatusResponseSuccess = (patchApiV10UserIdStatusResponse200) & {
headers: Headers;
};
export type patchApiV10UserIdStatusResponseError = (patchApiV10UserIdStatusResponse403 | patchApiV10UserIdStatusResponse404) & {
headers: Headers;
};
export type patchApiV10UserIdStatusResponse = (patchApiV10UserIdStatusResponseSuccess | patchApiV10UserIdStatusResponseError)
export const getPatchApiV10UserIdStatusUrl = (id: string,) => {
return `/api/v1.0/user/${id}/status`
}
export const patchApiV10UserIdStatus = async (id: string,
patchApiV10UserIdStatusBody: PatchApiV10UserIdStatusBody, options?: RequestInit): Promise<patchApiV10UserIdStatusResponse> => {
return useCustomClient<patchApiV10UserIdStatusResponse>(getPatchApiV10UserIdStatusUrl(id),
{
...options,
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
patchApiV10UserIdStatusBody,)
}
);}
export const getPatchApiV10UserIdStatusMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof patchApiV10UserIdStatus>>, TError,{id: string;data: BodyType<PatchApiV10UserIdStatusBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof patchApiV10UserIdStatus>>, TError,{id: string;data: BodyType<PatchApiV10UserIdStatusBody>}, TContext> => {
const mutationKey = ['patchApiV10UserIdStatus'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof patchApiV10UserIdStatus>>, {id: string;data: BodyType<PatchApiV10UserIdStatusBody>}> = (props) => {
const {id,data} = props ?? {};
return patchApiV10UserIdStatus(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PatchApiV10UserIdStatusMutationResult = NonNullable<Awaited<ReturnType<typeof patchApiV10UserIdStatus>>>
export type PatchApiV10UserIdStatusMutationBody = BodyType<PatchApiV10UserIdStatusBody>
export type PatchApiV10UserIdStatusMutationError = ErrorType<void>
/**
* @summary Toggle user status (enable/disable)
*/
export const usePatchApiV10UserIdStatus = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof patchApiV10UserIdStatus>>, TError,{id: string;data: BodyType<PatchApiV10UserIdStatusBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof patchApiV10UserIdStatus>>,
TError,
{id: string;data: BodyType<PatchApiV10UserIdStatusBody>},
TContext
> => {
const mutationOptions = getPatchApiV10UserIdStatusMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Assign one or more roles to a user. Replaces existing non-primary roles.
* @summary Assign role to user
*/
export type postApiV10UserIdRoleResponse200 = {
data: void
status: 200
}
export type postApiV10UserIdRoleResponse404 = {
data: void
status: 404
}
export type postApiV10UserIdRoleResponseSuccess = (postApiV10UserIdRoleResponse200) & {
headers: Headers;
};
export type postApiV10UserIdRoleResponseError = (postApiV10UserIdRoleResponse404) & {
headers: Headers;
};
export type postApiV10UserIdRoleResponse = (postApiV10UserIdRoleResponseSuccess | postApiV10UserIdRoleResponseError)
export const getPostApiV10UserIdRoleUrl = (id: string,) => {
return `/api/v1.0/user/${id}/role`
}
export const postApiV10UserIdRole = async (id: string,
postApiV10UserIdRoleBody: PostApiV10UserIdRoleBody, options?: RequestInit): Promise<postApiV10UserIdRoleResponse> => {
return useCustomClient<postApiV10UserIdRoleResponse>(getPostApiV10UserIdRoleUrl(id),
{
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
postApiV10UserIdRoleBody,)
}
);}
export const getPostApiV10UserIdRoleMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdRole>>, TError,{id: string;data: BodyType<PostApiV10UserIdRoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdRole>>, TError,{id: string;data: BodyType<PostApiV10UserIdRoleBody>}, TContext> => {
const mutationKey = ['postApiV10UserIdRole'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10UserIdRole>>, {id: string;data: BodyType<PostApiV10UserIdRoleBody>}> = (props) => {
const {id,data} = props ?? {};
return postApiV10UserIdRole(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10UserIdRoleMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10UserIdRole>>>
export type PostApiV10UserIdRoleMutationBody = BodyType<PostApiV10UserIdRoleBody>
export type PostApiV10UserIdRoleMutationError = ErrorType<void>
/**
* @summary Assign role to user
*/
export const usePostApiV10UserIdRole = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdRole>>, TError,{id: string;data: BodyType<PostApiV10UserIdRoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10UserIdRole>>,
TError,
{id: string;data: BodyType<PostApiV10UserIdRoleBody>},
TContext
> => {
const mutationOptions = getPostApiV10UserIdRoleMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Remove a role from a user
* @summary Remove role from user
*/
export type deleteApiV10UserIdRoleResponse200 = {
data: void
status: 200
}
export type deleteApiV10UserIdRoleResponse404 = {
data: void
status: 404
}
export type deleteApiV10UserIdRoleResponseSuccess = (deleteApiV10UserIdRoleResponse200) & {
headers: Headers;
};
export type deleteApiV10UserIdRoleResponseError = (deleteApiV10UserIdRoleResponse404) & {
headers: Headers;
};
export type deleteApiV10UserIdRoleResponse = (deleteApiV10UserIdRoleResponseSuccess | deleteApiV10UserIdRoleResponseError)
export const getDeleteApiV10UserIdRoleUrl = (id: string,) => {
return `/api/v1.0/user/${id}/role`
}
export const deleteApiV10UserIdRole = async (id: string,
deleteApiV10UserIdRoleBody: DeleteApiV10UserIdRoleBody, options?: RequestInit): Promise<deleteApiV10UserIdRoleResponse> => {
return useCustomClient<deleteApiV10UserIdRoleResponse>(getDeleteApiV10UserIdRoleUrl(id),
{
...options,
method: 'DELETE',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(
deleteApiV10UserIdRoleBody,)
}
);}
export const getDeleteApiV10UserIdRoleMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10UserIdRole>>, TError,{id: string;data: BodyType<DeleteApiV10UserIdRoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10UserIdRole>>, TError,{id: string;data: BodyType<DeleteApiV10UserIdRoleBody>}, TContext> => {
const mutationKey = ['deleteApiV10UserIdRole'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteApiV10UserIdRole>>, {id: string;data: BodyType<DeleteApiV10UserIdRoleBody>}> = (props) => {
const {id,data} = props ?? {};
return deleteApiV10UserIdRole(id,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteApiV10UserIdRoleMutationResult = NonNullable<Awaited<ReturnType<typeof deleteApiV10UserIdRole>>>
export type DeleteApiV10UserIdRoleMutationBody = BodyType<DeleteApiV10UserIdRoleBody>
export type DeleteApiV10UserIdRoleMutationError = ErrorType<void>
/**
* @summary Remove role from user
*/
export const useDeleteApiV10UserIdRole = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteApiV10UserIdRole>>, TError,{id: string;data: BodyType<DeleteApiV10UserIdRoleBody>}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteApiV10UserIdRole>>,
TError,
{id: string;data: BodyType<DeleteApiV10UserIdRoleBody>},
TContext
> => {
const mutationOptions = getDeleteApiV10UserIdRoleMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* Reset password của user về mật khẩu mặc định vcci@2026. Chỉ users:write mới có quyền.
* @summary Reset user password to default
*/
export type postApiV10UserIdResetPasswordResponse200 = {
data: void
status: 200
}
export type postApiV10UserIdResetPasswordResponse403 = {
data: void
status: 403
}
export type postApiV10UserIdResetPasswordResponse404 = {
data: void
status: 404
}
export type postApiV10UserIdResetPasswordResponseSuccess = (postApiV10UserIdResetPasswordResponse200) & {
headers: Headers;
};
export type postApiV10UserIdResetPasswordResponseError = (postApiV10UserIdResetPasswordResponse403 | postApiV10UserIdResetPasswordResponse404) & {
headers: Headers;
};
export type postApiV10UserIdResetPasswordResponse = (postApiV10UserIdResetPasswordResponseSuccess | postApiV10UserIdResetPasswordResponseError)
export const getPostApiV10UserIdResetPasswordUrl = (id: string,) => {
return `/api/v1.0/user/${id}/reset-password`
}
export const postApiV10UserIdResetPassword = async (id: string, options?: RequestInit): Promise<postApiV10UserIdResetPasswordResponse> => {
return useCustomClient<postApiV10UserIdResetPasswordResponse>(getPostApiV10UserIdResetPasswordUrl(id),
{
...options,
method: 'POST'
}
);}
export const getPostApiV10UserIdResetPasswordMutationOptions = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
): UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>, TError,{id: string}, TContext> => {
const mutationKey = ['postApiV10UserIdResetPassword'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>, {id: string}> = (props) => {
const {id} = props ?? {};
return postApiV10UserIdResetPassword(id,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type PostApiV10UserIdResetPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>>
export type PostApiV10UserIdResetPasswordMutationError = ErrorType<void>
/**
* @summary Reset user password to default
*/
export const usePostApiV10UserIdResetPassword = <TError = ErrorType<void>,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>, TError,{id: string}, TContext>, request?: SecondParameter<typeof useCustomClient>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof postApiV10UserIdResetPassword>>,
TError,
{id: string},
TContext
> => {
const mutationOptions = getPostApiV10UserIdResetPasswordMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
\ No newline at end of file
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { AdvertisementFile } from './advertisementFile';
import type { AdvertisementType } from './advertisementType';
import type { AdvertisementStatus } from './advertisementStatus';
export interface Advertisement {
id: string;
/** @maxLength 255 */
name: string;
file_id: string;
/** @nullable */
file?: AdvertisementFile;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link: string;
type: AdvertisementType;
status: AdvertisementStatus;
sort_order: number;
/** @nullable */
created_at?: string | null;
/** @nullable */
created_by?: string | null;
/** @nullable */
updated_at?: string | null;
/** @nullable */
updated_by?: string | null;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { AdvertisementCreateType } from './advertisementCreateType';
import type { AdvertisementCreateStatus } from './advertisementCreateStatus';
export interface AdvertisementCreate {
/** @maxLength 255 */
name: string;
file_id: string;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link: string;
type?: AdvertisementCreateType;
status?: AdvertisementCreateStatus;
sort_order?: number;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementCreateStatus = typeof AdvertisementCreateStatus[keyof typeof AdvertisementCreateStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementCreateStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementCreateType = typeof AdvertisementCreateType[keyof typeof AdvertisementCreateType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementCreateType = {
square: 'square',
horizontal: 'horizontal',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export interface AdvertisementFile {
id?: string;
path?: string;
mime?: string;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { AdvertisementCreate } from './advertisementCreate';
export type AdvertisementMutate = AdvertisementCreate;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementStatus = typeof AdvertisementStatus[keyof typeof AdvertisementStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementType = typeof AdvertisementType[keyof typeof AdvertisementType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementType = {
square: 'square',
horizontal: 'horizontal',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { AdvertisementUpdateType } from './advertisementUpdateType';
import type { AdvertisementUpdateStatus } from './advertisementUpdateStatus';
export interface AdvertisementUpdate {
/** @maxLength 255 */
name?: string;
file_id?: string;
/**
* @maxLength 255
* @nullable
*/
alt?: string | null;
/** @maxLength 500 */
link?: string;
type?: AdvertisementUpdateType;
status?: AdvertisementUpdateStatus;
sort_order?: number;
}
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementUpdateStatus = typeof AdvertisementUpdateStatus[keyof typeof AdvertisementUpdateStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementUpdateStatus = {
ACTIVE: 'ACTIVE',
INACTIVE: 'INACTIVE',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type AdvertisementUpdateType = typeof AdvertisementUpdateType[keyof typeof AdvertisementUpdateType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AdvertisementUpdateType = {
square: 'square',
horizontal: 'horizontal',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { DeleteApiV10AdvertisementId200AllOf } from './deleteApiV10AdvertisementId200AllOf';
export type DeleteApiV10AdvertisementId200 = ApiResponse & DeleteApiV10AdvertisementId200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type DeleteApiV10AdvertisementId200AllOf = {
responseData?: boolean;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type DeleteApiV10UserIdRoleBody = {
/** Role ID to remove */
role_id?: string;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { GetApiV10AdvertisementId200AllOf } from './getApiV10AdvertisementId200AllOf';
export type GetApiV10AdvertisementId200 = ApiResponse & GetApiV10AdvertisementId200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { Advertisement } from './advertisement';
export type GetApiV10AdvertisementId200AllOf = {
responseData?: Advertisement;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { FiltersParameter } from './filtersParameter';
import type { SortFieldParameter } from './sortFieldParameter';
import type { SortOrderParameter } from './sortOrderParameter';
import type { PageParameter } from './pageParameter';
import type { PageSizeParameter } from './pageSizeParameter';
export type GetApiV10AdvertisementParams = {
/**
* filter, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
filters?: FiltersParameter;
/**
* sortField, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
sortField?: SortFieldParameter;
/**
* sort order, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
*/
sortOrder?: SortOrderParameter;
/**
* page, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
* @minimum 1
*/
page?: PageParameter;
/**
* pageSize, visit https://www.npmjs.com/package/sequelize-api-paginate for syntax
* @minimum 1
*/
pageSize?: PageSizeParameter;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { GetApiV10AdvertisementPublic200AllOf } from './getApiV10AdvertisementPublic200AllOf';
export type GetApiV10AdvertisementPublic200 = ApiResponse & GetApiV10AdvertisementPublic200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { Advertisement } from './advertisement';
export type GetApiV10AdvertisementPublic200AllOf = {
responseData?: Advertisement[];
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { GetApiV10AdvertisementPublicType } from './getApiV10AdvertisementPublicType';
export type GetApiV10AdvertisementPublicParams = {
/**
* Advertisement type (square or horizontal). If omitted, returns all active.
*/
type?: GetApiV10AdvertisementPublicType;
/**
* Max number of records to return (default 20)
* @minimum 1
* @maximum 100
*/
limit?: number;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type GetApiV10AdvertisementPublicType = typeof GetApiV10AdvertisementPublicType[keyof typeof GetApiV10AdvertisementPublicType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10AdvertisementPublicType = {
square: 'square',
horizontal: 'horizontal',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { GetApiV10PasswordResetRequestStatus } from './getApiV10PasswordResetRequestStatus';
import type { GetApiV10PasswordResetRequestSortOrder } from './getApiV10PasswordResetRequestSortOrder';
export type GetApiV10PasswordResetRequestParams = {
page?: number;
pageSize?: number;
status?: GetApiV10PasswordResetRequestStatus;
sortField?: string;
sortOrder?: GetApiV10PasswordResetRequestSortOrder;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type GetApiV10PasswordResetRequestSortOrder = typeof GetApiV10PasswordResetRequestSortOrder[keyof typeof GetApiV10PasswordResetRequestSortOrder];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10PasswordResetRequestSortOrder = {
asc: 'asc',
desc: 'desc',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type GetApiV10PasswordResetRequestStatus = typeof GetApiV10PasswordResetRequestStatus[keyof typeof GetApiV10PasswordResetRequestStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const GetApiV10PasswordResetRequestStatus = {
PENDING: 'PENDING',
RESOLVED: 'RESOLVED',
REJECTED: 'REJECTED',
all: 'all',
} as const;
......@@ -6,6 +6,17 @@
* OpenAPI spec version: 1.0.0
*/
export * from './advertisement';
export * from './advertisementCreate';
export * from './advertisementCreateStatus';
export * from './advertisementCreateType';
export * from './advertisementFile';
export * from './advertisementMutate';
export * from './advertisementStatus';
export * from './advertisementType';
export * from './advertisementUpdate';
export * from './advertisementUpdateStatus';
export * from './advertisementUpdateType';
export * from './apiResponse';
export * from './apiResponseResponseData';
export * from './apiResponseViolationItem';
......@@ -41,6 +52,8 @@ export * from './categoryTagMutate';
export * from './categoryThumbnail';
export * from './contact';
export * from './contactMutate';
export * from './deleteApiV10AdvertisementId200';
export * from './deleteApiV10AdvertisementId200AllOf';
export * from './deleteApiV10BannerId200';
export * from './deleteApiV10BannerId200AllOf';
export * from './deleteApiV10CategoryId200';
......@@ -69,6 +82,7 @@ export * from './deleteApiV10RolePermissionRoleRoleId200AllOf';
export * from './deleteApiV10RolePermissionRoleRoleId200AllOfResponseData';
export * from './deleteApiV10TermId200';
export * from './deleteApiV10TermId200AllOf';
export * from './deleteApiV10UserIdRoleBody';
export * from './deleteApiV10UserRoleId200';
export * from './deleteApiV10UserRoleId200AllOf';
export * from './deleteApiV10UserRoleId200AllOfResponseData';
......@@ -84,6 +98,13 @@ export * from './forgotPasswordResetRequest';
export * from './forgotPasswordSendOtpRequest';
export * from './forgotPasswordVerifyOtpRequest';
export * from './forgotPasswordVerifyOtpResponse';
export * from './getApiV10AdvertisementId200';
export * from './getApiV10AdvertisementId200AllOf';
export * from './getApiV10AdvertisementParams';
export * from './getApiV10AdvertisementPublic200';
export * from './getApiV10AdvertisementPublic200AllOf';
export * from './getApiV10AdvertisementPublicParams';
export * from './getApiV10AdvertisementPublicType';
export * from './getApiV10AuthMe200';
export * from './getApiV10AuthMe200AllOf';
export * from './getApiV10AuthProfile200';
......@@ -109,6 +130,9 @@ export * from './getApiV10LogoParams';
export * from './getApiV10MemberParams';
export * from './getApiV10NewsletterSubscriptionParams';
export * from './getApiV10PageConfigParams';
export * from './getApiV10PasswordResetRequestParams';
export * from './getApiV10PasswordResetRequestSortOrder';
export * from './getApiV10PasswordResetRequestStatus';
export * from './getApiV10PermissionId200';
export * from './getApiV10PermissionId200AllOf';
export * from './getApiV10PermissionParams';
......@@ -189,6 +213,8 @@ export * from './patchApiV10NewsletterSubscriptionId200';
export * from './patchApiV10NewsletterSubscriptionId200AllOf';
export * from './patchApiV10TagId200';
export * from './patchApiV10TagId200AllOf';
export * from './patchApiV10UserIdStatusBody';
export * from './patchApiV10UserIdStatusBodyStatus';
export * from './patchApiV10VideoId200';
export * from './patchApiV10VideoId200AllOf';
export * from './permission';
......@@ -200,6 +226,9 @@ export * from './permissionUpdate';
export * from './position';
export * from './positionMutate';
export * from './post';
export * from './postApiV10Advertisement200';
export * from './postApiV10Advertisement200AllOf';
export * from './postApiV10AuthForgotPasswordRequestBody';
export * from './postApiV10AuthForgotPasswordVerifyOtp200';
export * from './postApiV10AuthLogin200';
export * from './postApiV10AuthLogin200Message';
......@@ -234,6 +263,8 @@ export * from './postApiV10MemberImportBody';
export * from './postApiV10NewsletterSubscription200';
export * from './postApiV10NewsletterSubscription200AllOf';
export * from './postApiV10PageConfigBody';
export * from './postApiV10PasswordResetRequestIdRejectBody';
export * from './postApiV10PasswordResetRequestIdResolveBody';
export * from './postApiV10PermissionBulk200';
export * from './postApiV10PermissionBulk200AllOf';
export * from './postApiV10Position200';
......@@ -264,6 +295,7 @@ export * from './postApiV10PostTagPostIdBulk200';
export * from './postApiV10PostTagPostIdBulk200AllOf';
export * from './postApiV10PostTagPostIdBulk200AllOfResponseDataItem';
export * from './postApiV10PostTagPostIdBulkBody';
export * from './postApiV10RoleBody';
export * from './postApiV10RoleBulk200';
export * from './postApiV10RoleBulk200AllOf';
export * from './postApiV10RolePermissionRoleRoleIdBulk200';
......@@ -275,6 +307,7 @@ export * from './postApiV10TagIds200AllOf';
export * from './postApiV10TagIdsBody';
export * from './postApiV10Term201';
export * from './postApiV10Term201AllOf';
export * from './postApiV10UserIdRoleBody';
export * from './postApiV10UserRole200';
export * from './postApiV10UserRole200AllOf';
export * from './postApiV10Video200';
......@@ -288,6 +321,8 @@ export * from './postMutateContentStructure';
export * from './postTag';
export * from './postTagBulkCreate';
export * from './postTagMutate';
export * from './putApiV10AdvertisementId200';
export * from './putApiV10AdvertisementId200AllOf';
export * from './putApiV10BannerId200';
export * from './putApiV10BannerId200AllOf';
export * from './putApiV10CategoryId200';
......
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { PatchApiV10UserIdStatusBodyStatus } from './patchApiV10UserIdStatusBodyStatus';
export type PatchApiV10UserIdStatusBody = {
/** Trạng thái mới của user */
status?: PatchApiV10UserIdStatusBodyStatus;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* Trạng thái mới của user
*/
export type PatchApiV10UserIdStatusBodyStatus = typeof PatchApiV10UserIdStatusBodyStatus[keyof typeof PatchApiV10UserIdStatusBodyStatus];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const PatchApiV10UserIdStatusBodyStatus = {
active: 'active',
inactive: 'inactive',
} as const;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { PostApiV10Advertisement200AllOf } from './postApiV10Advertisement200AllOf';
export type PostApiV10Advertisement200 = ApiResponse & PostApiV10Advertisement200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { Advertisement } from './advertisement';
export type PostApiV10Advertisement200AllOf = {
responseData?: Advertisement;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostApiV10AuthForgotPasswordRequestBody = {
email: string;
/** Ghi chú thêm từ user (tên, lý do, etc.) */
note?: string;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostApiV10PasswordResetRequestIdRejectBody = {
/** Lý do từ chối */
resolveNote?: string;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostApiV10PasswordResetRequestIdResolveBody = {
/** Mật khẩu mới (nếu bỏ trống sẽ dùng vcci@2026) */
newPassword?: string;
/** Ghi chú của admin */
resolveNote?: string;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostApiV10RoleBody = {
name?: string;
description?: string;
permissions?: string[];
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
export type PostApiV10UserIdRoleBody = {
/** Role ID to assign */
role_id?: string;
/** Set as primary role */
is_primary?: boolean;
};
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { ApiResponse } from './apiResponse';
import type { PutApiV10AdvertisementId200AllOf } from './putApiV10AdvertisementId200AllOf';
export type PutApiV10AdvertisementId200 = ApiResponse & PutApiV10AdvertisementId200AllOf;
/**
* Generated by orval v8.0.0-rc.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
import type { Advertisement } from './advertisement';
export type PutApiV10AdvertisementId200AllOf = {
responseData?: Advertisement;
};
......@@ -23,4 +23,6 @@ export interface Role {
created_by?: string | null;
/** @nullable */
updated_by?: string | null;
/** Danh sách permission strings (vd: 'posts:read', 'posts:write') */
permissions?: string[];
}
......@@ -14,4 +14,6 @@ export interface RoleCreate {
* @nullable
*/
description?: string | null;
/** Danh sách permission strings */
permissions?: string[];
}
......@@ -14,4 +14,6 @@ export interface RoleUpdate {
* @nullable
*/
description?: string | null;
/** Danh sách permission strings (ghi đà toàn bộ permissions hiện tại) */
permissions?: string[];
}
......@@ -3,8 +3,9 @@
import ImageNext from "@/components/shared/image-next";
import { useHomePosts } from "@/app/(main)/(home)/lib/use-home-posts";
import dayjs from "dayjs";
import { ChevronRight, Mail, Phone } from "lucide-react";
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import HorizontalAdBanner from "@/app/(main)/(home)/components/horizontal-ad-banner";
const FALLBACK_CATEGORY_LINK = "/hoat-dong/tin-tuc";
......@@ -36,7 +37,7 @@ function FeaturedNews() {
</Link>
</div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.14fr)_minmax(0,0.96fr)]">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
{primaryItem ? (
<Link
href={primaryItem.externalLink}
......@@ -57,7 +58,7 @@ function FeaturedNews() {
{primaryItem.categories[0]?.name || "Tin nổi bật"}
</span>
<h3 className="max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[28px] lg:text-[32px]">
<h3 className="max-w-3xl line-clamp-2 text-[16px] font-bold leading-[1.32] text-white transition-colors duration-200 group-hover:text-[#f7b500] md:line-clamp-3 md:text-[22px] lg:text-[24px]">
{primaryItem.title}
</h3>
......@@ -79,16 +80,16 @@ function FeaturedNews() {
</div>
)}
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-4 lg:min-h-[380px]">
<div className="grid flex-1 gap-4 md:grid-cols-2">
{secondarySlots.map((item, index) =>
item ? (
<Link
key={item.id}
href={item.externalLink}
className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] md:min-h-[205px] lg:min-h-[215px]"
className="group relative block cursor-pointer overflow-hidden rounded-[20px] bg-[#27447f] shadow-[0_16px_32px_rgba(28,52,120,0.2)] min-h-[165px]"
>
<div className="relative h-full min-h-[195px] md:min-h-[205px] lg:min-h-[215px]">
<div className="relative flex h-full min-h-[165px]">
<ImageNext
src={item.thumbnail?.url ?? "/thumbnail.png"}
alt={item.thumbnail?.alt || item.title}
......@@ -118,9 +119,9 @@ function FeaturedNews() {
) : (
<div
key={`featured-placeholder-${index}`}
className="rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] md:min-h-[205px] lg:min-h-[215px]"
className="rounded-[20px] bg-[#dde5f3] shadow-[0_16px_32px_rgba(28,52,120,0.1)] min-h-[165px]"
>
<div className="flex h-full min-h-[195px] flex-col justify-end p-3.5 md:min-h-[205px] lg:min-h-[215px]">
<div className="flex h-full min-h-[165px] flex-col justify-end p-3.5">
<span className="mb-2 h-7 w-24 rounded-[10px] bg-white/80" />
<div className="h-6 w-5/6 rounded bg-white/90" />
<div className="mt-2 h-4 w-24 rounded bg-white/70" />
......@@ -130,32 +131,7 @@ function FeaturedNews() {
)}
</div>
<div className="flex h-full min-h-full items-center justify-center overflow-hidden rounded-[28px] bg-linear-to-r from-[#214b95] to-[#2b66bb] px-5 py-5 text-white shadow-[0_18px_38px_rgba(28,52,120,0.2)] md:px-7">
<div className="flex w-full flex-col items-center justify-center gap-4 text-center md:flex-row md:gap-6 md:text-left lg:gap-8 xl:gap-12">
<div className="flex flex-col items-center md:items-start">
<p className="text-[11px] uppercase tracking-[0.2em] text-white/80 lg:text-[12px] lg:tracking-[0.4em]">
Quảng bá & tiếp cận
</p>
<h3 className="mt-2 text-[20px] font-extrabold uppercase leading-[1.1] lg:text-[18px] xl:text-[22px]">
<span>Cộng đồng</span>
<br className="hidden lg:block" />
<span className="lg:hidden"> </span>
<span>doanh nghiệp</span>
</h3>
</div>
<div className="shrink-0 flex flex-col gap-2 rounded-[20px] bg-white px-4 py-3 text-[#173f88] shadow-[0_10px_24px_rgba(8,25,74,0.12)] sm:min-w-fit lg:w-auto lg:rounded-[999px]">
<div className="flex items-center gap-2 text-[13px] font-medium sm:text-sm">
<Mail className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span className="whitespace-nowrap">info@vcci-hcm.org.vn</span>
</div>
<div className="flex items-center gap-2 text-[13px] font-medium sm:text-sm">
<Phone className="h-4 w-4 shrink-0 sm:h-5 sm:w-5" />
<span className="whitespace-nowrap">+84 28 3932 6598</span>
</div>
</div>
</div>
</div>
<HorizontalAdBanner />
</div>
</div>
</div>
......
'use client';
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { resolveUploadUrl } from "@/links";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
const FALLBACK_SRC = "/quang-cao/qc-1.jpg";
/**
* Banner quảng cáo ngang (full-width) giữa các section.
* Luôn hiển thị 1 banner duy nhất (record đầu tiên theo sort_order).
* Dù có nhiều vị trí đặt component, tất cả đều hiển thị cùng 1 banner.
* Fallback: nếu API không có data hoặc ảnh lỗi → dùng /quang-cao/qc-1.jpg.
*/
function HorizontalAdBanner() {
const ads = useAdvertisements("horizontal");
const ad = ads[0];
const href = ad?.link || FALLBACK_HREF;
const initialSrc = ad?.file?.path ? resolveUploadUrl(ad.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc);
const [errored, setErrored] = useState(false);
// Reset state khi ad thay đổi
if (ad?.file?.path && !errored && src !== initialSrc) {
setSrc(initialSrc);
}
const isGif = src.toLowerCase().endsWith(".gif");
const title = ad?.name || "Quảng cáo VCCI HCM";
const alt = ad?.alt || title;
return (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="relative block overflow-hidden rounded-[10px] shadow-[0_16px_32px_rgba(28,52,120,0.2)] lg:rounded-[20px]"
style={{ aspectRatio: "1600 / 200" }}
title={title}
>
<Image
src={src}
alt={alt}
fill
className="object-cover"
unoptimized={isGif}
onError={() => {
if (src !== FALLBACK_SRC) {
setSrc(FALLBACK_SRC);
setErrored(true);
}
}}
/>
</Link>
);
}
export default HorizontalAdBanner;
'use client';
import ImageNext from "@/components/shared/image-next";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { resolveUploadUrl } from "@/links";
import type { Advertisement } from "@/api/models/advertisement";
const FALLBACK_SRC = "/quang-cao/qc-3.jpg";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
function AdItem({ item }: { item: Advertisement }) {
const initialSrc = item.file?.path ? resolveUploadUrl(item.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc);
const isGif = src.toLowerCase().endsWith(".gif");
function Advertisements({ count = 2 }: { count?: number }) {
const mdCols = count >= 3 ? "md:grid-cols-3" : "md:grid-cols-2";
return (
<aside className={`flex w-full flex-col gap-4 md:grid ${mdCols} xl:grid xl:order-2 xl:w-[22%] xl:grid-cols-1 xl:gap-4 xl:self-center`}>
<Link
href="https://hardwaretools.com.vn/"
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title={item.name}
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<ImageNext
src="/home/20-2048x1365.webp"
alt="Quảng cáo 1"
<Image
src={src}
alt={item.alt || item.name}
width={2048}
height={1365}
className="h-full w-full object-cover object-[center_80%]"
unoptimized={isGif}
onError={() => {
if (src !== FALLBACK_SRC) setSrc(FALLBACK_SRC);
}}
/>
</div>
</Link>
);
}
function FallbackAdItem() {
return (
<Link
href="https://hardwaretools.com.vn/"
href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
title="Quảng cáo VCCI HCM"
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<ImageNext
src="/home/20-2048x1365.webp"
alt="Quảng cáo 2"
<Image
src={FALLBACK_SRC}
alt="Quảng cáo VCCI HCM"
width={2048}
height={1365}
className="h-full w-full object-cover object-[center_80%]"
/>
</div>
</Link>
);
}
{count >= 3 && (
<Link
href="https://hardwaretools.com.vn/"
className="block overflow-hidden rounded-[28px] shadow-[0_12px_28px_rgba(31,59,124,0.14)]"
>
<div className="aspect-[16/10] overflow-hidden sm:aspect-[16/10] lg:aspect-[7/4] xl:aspect-[3/2]">
<ImageNext
src="/home/20-2048x1365.webp"
alt="Quảng cáo 3"
width={2048}
height={1365}
className="h-full w-full object-cover object-[center_80%]"
/>
</div>
</Link>
)}
function Advertisements({ count = 2, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count);
// Fallback: nếu API không có data, hiển thị fallback item
const items =
visibleAds.length > 0
? visibleAds.map((item) => <AdItem key={item.id} item={item} />)
: Array.from({ length: count }).map((_, i) => <FallbackAdItem key={`fallback-${i}`} />);
const mdCols = count >= 3 ? "md:grid-cols-3" : "md:grid-cols-2";
return (
<aside className={`flex w-full flex-col gap-4 md:grid ${mdCols} xl:grid xl:order-2 xl:w-[22%] xl:grid-cols-1 xl:gap-4 xl:self-center`}>
{items}
</aside>
);
}
......
"use client";
import { useGetApiV10AdvertisementPublic } from "@/api/endpoints/advertisement";
import type { Advertisement } from "@/api/models/advertisement";
import type { GetApiV10AdvertisementPublicType } from "@/api/models/getApiV10AdvertisementPublicType";
/**
* Hook đọc danh sách quảng cáo active theo loại từ API backend (public, không cần auth).
* Backend đã lọc status=ACTIVE và sort theo sort_order ASC.
* Mỗi record có `file` (path, mime) — dùng `resolveUploadUrl(file.path)` để lấy URL ảnh.
*
* @param type - "square" | "horizontal"
* @param limit - số lượng records tối đa (mặc định 20 cho square, 1 cho horizontal)
*/
export function useAdvertisements(
type: "square" | "horizontal",
limit?: number,
): Advertisement[] {
const effectiveLimit = limit ?? (type === "horizontal" ? 1 : 20);
const { data } = useGetApiV10AdvertisementPublic({
type: type as GetApiV10AdvertisementPublicType,
limit: effectiveLimit,
});
return (
(data as unknown as { responseData?: Advertisement[] } | undefined)?.responseData ?? []
);
}
......@@ -2,6 +2,7 @@
import FeaturedNews from "./components/featured-news";
import Advertisements from "./components/quick-links";
import HorizontalAdBanner from "./components/horizontal-ad-banner";
import News from "./components/news";
import Events from "./components/events";
import BusinessOpportunities from "./components/business-opportunities";
......@@ -18,22 +19,15 @@ const Page = () => {
{/* contents */}
<div className="container mx-auto px-3 sm:px-6 lg:px-10 space-y-6">
<FeaturedNews />
{/* <div>
<Link href="https://hardwaretools.com.vn/">
<ImageNext
src="/home/Standard-Banner-1-2024.png.webp"
alt="banner"
width={2560}
height={720}
/>
</Link>
</div> */}
<section className="flex flex-col xl:flex-row pb-8 gap-5 mb-0">
<News />
<Advertisements count={3} />
<Advertisements count={3} startIndex={0} />
</section >
<HorizontalAdBanner />
<section className="flex flex-col gap-5 xl:flex-row xl:items-stretch" >
<Events />
<EventsCalendar />
......@@ -57,7 +51,7 @@ const Page = () => {
<BusinessOpportunities />
<PolicyAndLaws />
</div>
<Advertisements />
<Advertisements count={2} startIndex={3} />
</section>
</div>
......
......@@ -5,6 +5,7 @@ import ImageNext from "@/components/shared/image-next";
import AppEditorContent from "@/components/shared/editor-content";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Calendar, MapPin, Clock, DollarSign, Users, CreditCard } from "lucide-react";
import { buildDynamicCategoryMenu, findDisplayCategoryForPost } from "./data";
import StructuredPostContent from "./StructuredPostContent";
......@@ -254,26 +255,7 @@ export default function ArticleDetailPage({
<aside className="space-y-5 xl:pt-0">
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
</div>
</div>
......
......@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import {
buildDynamicPostHref,
buildDynamicCategoryMenu,
......@@ -250,26 +251,7 @@ export default function ArticlePage({ category, allCategories }: ArticlePageProp
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
</div>
</div>
......
......@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import ListCategory from "@/components/base/list-category";
import EventsCalendar from "@/app/(main)/(home)/components/events-calendar";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import {
buildDynamicPostHref,
buildDynamicCategoryMenu,
......@@ -188,26 +189,7 @@ export default function CatalogPage({ category, allCategories }: CatalogPageProp
<EventsCalendar compact className="xl:w-full xl:min-w-0" />
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-0">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-liner-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho hội viên doanh nghiệp
</div>
</div>
</div>
</div>
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
</div>
</div>
......
......@@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import ImageNext from "@/components/shared/image-next";
import SidebarAdvertisements from "@/components/shared/sidebar-advertisements";
import { Pagination } from "@components/base/pagination";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
......@@ -228,26 +229,7 @@ function SearchContent() {
</div>
</form>
<div className="order-3 overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)] xl:order-none">
<div className="relative min-h-[390px] bg-[#1f334f]">
<ImageNext
src="/banner.webp"
alt="Đối tác quảng bá"
width={640}
height={760}
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#14213d]/92 via-[#14213d]/28 to-transparent" />
<div className="absolute bottom-8 left-7 right-7 text-white">
<div className="text-xs font-semibold uppercase tracking-[0.24em] text-white/70">
Đối tác quảng bá
</div>
<div className="mt-3 text-2xl font-bold leading-tight">
Business Combo cho doanh nghiệp hội viên
</div>
</div>
</div>
</div>
<SidebarAdvertisements count={5} startIndex={0} />
</aside>
</div>
</div>
......
"use client";
import React, { useState } from "react";
import {
Plus,
Edit,
Trash2,
Loader2,
ChevronLeft,
ChevronRight,
CheckCircle,
XCircle,
ImagePlus,
ExternalLink,
Info,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { SafeNextImage } from "@/components/admin/safe-next-image";
import type { AdminMediaItem } from "@/mockdata/admin-news";
import { resolveUploadUrl } from "@/links";
import { useQueryClient } from "@tanstack/react-query";
// API imports
import {
useGetApiV10Advertisement,
usePostApiV10Advertisement,
usePutApiV10AdvertisementId,
useDeleteApiV10AdvertisementId,
} from "@/api/endpoints/advertisement";
import type { Advertisement } from "@/api/models/advertisement";
import type { AdvertisementCreate } from "@/api/models/advertisementCreate";
import type { AdvertisementUpdate } from "@/api/models/advertisementUpdate";
const PAGE_SIZE = 10;
type AdType = "square" | "horizontal";
interface FormValues {
name: string;
file_id: string;
filePreviewUrl?: string;
alt: string;
link: string;
status: "ACTIVE" | "INACTIVE";
sort_order: number;
}
interface AdvertisementListProps {
type: AdType;
title: string;
description: string;
previewAspect?: string; // CSS aspect-ratio
note?: string; // Lưu ý hiển thị trên website
}
export function AdvertisementList({
type,
title,
description,
previewAspect = "16 / 10",
note,
}: AdvertisementListProps) {
const canRead = usePermission("advertisements", "read");
const canWrite = usePermission("advertisements", "write");
const canDelete = usePermission("advertisements", "delete");
const queryClient = useQueryClient();
const [currentPage, setCurrentPage] = useState(1);
const [filterStatus, setFilterStatus] = useState<"all" | "ACTIVE" | "INACTIVE">("all");
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [selectedAd, setSelectedAd] = useState<Advertisement | null>(null);
const [form, setForm] = useState<FormValues>({
name: "",
file_id: "",
filePreviewUrl: "",
alt: "",
link: "",
status: "ACTIVE",
sort_order: 1,
});
const [isImagePickerOpen, setIsImagePickerOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [adToDelete, setAdToDelete] = useState<Advertisement | null>(null);
const filters = React.useMemo(() => {
const parts: string[] = [`type==${type}`];
if (filterStatus !== "all") parts.push(`status==${filterStatus}`);
return parts.join(",");
}, [type, filterStatus]);
const { data: adsData, isLoading } = useGetApiV10Advertisement({
page: currentPage,
pageSize: PAGE_SIZE,
sortField: "sort_order",
sortOrder: "asc",
filters,
});
const createMutation = usePostApiV10Advertisement();
const updateMutation = usePutApiV10AdvertisementId();
const deleteMutation = useDeleteApiV10AdvertisementId();
const ads =
((adsData as unknown as { responseData?: { rows?: Advertisement[] } })?.responseData?.rows) || [];
const total =
((adsData as unknown as { responseData?: { count?: number } })?.responseData?.count) || 0;
const handleCreate = () => {
setSelectedAd(null);
setForm({
name: "",
file_id: "",
filePreviewUrl: "",
alt: "",
link: "",
status: "ACTIVE",
sort_order: 1,
});
setIsEditDialogOpen(true);
};
const handleEdit = (ad: Advertisement) => {
setSelectedAd(ad);
setForm({
name: ad.name,
file_id: ad.file_id,
filePreviewUrl: ad.file?.path ? resolveUploadUrl(ad.file.path) : "",
alt: ad.alt || "",
link: ad.link,
status: ad.status as "ACTIVE" | "INACTIVE",
sort_order: ad.sort_order,
});
setIsEditDialogOpen(true);
};
const handleDelete = (ad: Advertisement) => {
setAdToDelete(ad);
setIsDeleteDialogOpen(true);
};
const handleSelectImage = (item: AdminMediaItem) => {
setForm((prev) => ({
...prev,
file_id: item.id,
filePreviewUrl: item.url,
}));
};
const handleSave = async () => {
if (!form.name.trim()) {
toast.error("Vui lòng nhập tên quảng cáo");
return;
}
if (!form.file_id) {
toast.error("Vui lòng chọn ảnh quảng cáo");
return;
}
if (!form.link.trim()) {
toast.error("Vui lòng nhập đường link");
return;
}
try {
if (selectedAd) {
const updateData: AdvertisementUpdate = {
name: form.name.trim(),
file_id: form.file_id,
alt: form.alt.trim() || null,
link: form.link.trim(),
type,
status: form.status,
sort_order: form.sort_order,
};
await updateMutation.mutateAsync({ id: selectedAd.id, data: updateData });
toast.success("Cập nhật quảng cáo thành công!");
} else {
const createData: AdvertisementCreate = {
name: form.name.trim(),
file_id: form.file_id,
alt: form.alt.trim() || null,
link: form.link.trim(),
type,
status: form.status,
sort_order: form.sort_order,
};
await createMutation.mutateAsync({ data: createData });
toast.success("Tạo quảng cáo thành công!");
}
setIsEditDialogOpen(false);
queryClient.invalidateQueries({
queryKey: ["/api/v1.0/advertisement"],
exact: false,
});
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Lưu quảng cáo thất bại");
}
};
const handleConfirmDelete = async () => {
if (!adToDelete) return;
try {
await deleteMutation.mutateAsync({ id: adToDelete.id });
toast.success("Xóa quảng cáo thành công!");
setIsDeleteDialogOpen(false);
queryClient.invalidateQueries({
queryKey: ["/api/v1.0/advertisement"],
exact: false,
});
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Xóa quảng cáo thất bại");
}
};
if (!canRead) {
return (
<div className="rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] p-10 text-center">
<p className="text-slate-500">Bạn không có quyền xem quảng cáo</p>
</div>
);
}
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-[#163b73]">{title}</h2>
<p className="mt-0.5 text-sm text-slate-600">
{description} ({total} quảng cáo)
</p>
</div>
<PermissionGate required="advertisements:write">
<Button
onClick={handleCreate}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Tạo mới
</Button>
</PermissionGate>
</div>
{/* Note */}
{note && (
<div className="flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<p className="text-sm text-amber-800">
<span className="font-semibold">Lưu ý hiển thị:</span> {note}
</p>
</div>
)}
{/* Filter status */}
<div className="flex items-center gap-2">
<Label className="text-sm text-slate-600">Trạng thái:</Label>
<Select
value={filterStatus}
onValueChange={(v) => {
setFilterStatus(v as typeof filterStatus);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[140px] rounded-xl border-[#063e8e]/15">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả</SelectItem>
<SelectItem value="ACTIVE">Hiển thị</SelectItem>
<SelectItem value="INACTIVE">Ẩn</SelectItem>
</SelectContent>
</Select>
</div>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
)}
{/* Table */}
{!isLoading && (
<div className="rounded-2xl border border-[#063e8e]/10 bg-white overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-10 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
STT
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Ảnh
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Tên quảng cáo
</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
Thứ tự
</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
Trạng thái
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Link
</TableHead>
<TableHead className="w-24 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
Thao tác
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ads.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-slate-500">
Không tìm thấy quảng cáo nào
</TableCell>
</TableRow>
) : (
ads.map((ad, index) => {
const imageUrl = ad.file?.path ? resolveUploadUrl(ad.file.path) : "";
const isGif = imageUrl.toLowerCase().endsWith(".gif");
return (
<TableRow
key={ad.id}
className="border-b border-[#063e8e]/5 hover:bg-[#063e8e]/2"
>
<TableCell className="text-center text-slate-600 whitespace-nowrap">
{(currentPage - 1) * PAGE_SIZE + index + 1}
</TableCell>
<TableCell>
<div
className="relative overflow-hidden rounded-lg border border-[#063e8e]/10 bg-[#f8fbff]"
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 112 : 72 }}
>
{imageUrl ? (
<SafeNextImage
src={imageUrl}
alt={ad.alt || ad.name}
fill
className="object-cover"
unoptimized={isGif}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImagePlus className="h-5 w-5 text-slate-400" />
</div>
)}
</div>
</TableCell>
<TableCell>
<p className="text-sm font-medium text-[#163b73] truncate max-w-[200px]">
{ad.name}
</p>
{ad.alt && (
<p className="text-sm text-slate-400 truncate max-w-[200px]">
{ad.alt}
</p>
)}
</TableCell>
<TableCell className="text-center">
<span className="text-sm font-medium text-slate-700">
{ad.sort_order}
</span>
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={`text-sm whitespace-nowrap ${
ad.status === "ACTIVE"
? "border-green-200 bg-green-50 text-green-700"
: "border-red-200 bg-red-50 text-red-700"
}`}
>
{ad.status === "ACTIVE" ? (
<CheckCircle className="mr-1 h-4 w-4" />
) : (
<XCircle className="mr-1 h-4 w-4" />
)}
{ad.status === "ACTIVE" ? "Hiển thị" : "Ẩn"}
</Badge>
</TableCell>
<TableCell>
<a
href={ad.link}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#063e8e] hover:underline flex items-center gap-1 truncate max-w-[180px]"
>
<ExternalLink className="h-4 w-4 shrink-0" />
<span className="truncate">{ad.link}</span>
</a>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<PermissionGate required="advertisements:write">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(ad)}
className="h-9 rounded-lg text-[#063e8e] hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Edit className="h-4 w-4" />
</Button>
</PermissionGate>
<PermissionGate required="advertisements:delete">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(ad)}
className="h-9 rounded-lg text-red-600 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-4 w-4" />
</Button>
</PermissionGate>
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
)}
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Hiển thị {(currentPage - 1) * PAGE_SIZE + 1} -{" "}
{Math.min(currentPage * PAGE_SIZE, total)} trong {total} quảng cáo
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || isLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm font-medium text-[#163b73]">
Trang {currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={currentPage >= totalPages || isLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Create/Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden rounded-3xl border-[#063e8e]/15">
<DialogHeader>
<DialogTitle className="text-xl text-[#163b73]">
{selectedAd ? `Sửa ${title.toLowerCase()}` : `Tạo ${title.toLowerCase()} mới`}
</DialogTitle>
<DialogDescription>
{selectedAd ? "Cập nhật thông tin quảng cáo" : description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto px-1 py-2 max-h-[60vh]">
{/* Image picker */}
<div className="space-y-2">
<Label className="text-sm font-medium">Ảnh quảng cáo *</Label>
<div className="flex items-center gap-4">
<div
className="relative shrink-0 overflow-hidden rounded-xl border border-[#063e8e]/15 bg-[#f8fbff]"
style={{ aspectRatio: previewAspect, width: type === "horizontal" ? 200 : 120 }}
>
{form.filePreviewUrl ? (
<SafeNextImage
src={form.filePreviewUrl}
alt="Preview"
fill
className="object-cover"
unoptimized={form.filePreviewUrl.toLowerCase().endsWith(".gif")}
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImagePlus className="h-6 w-6 text-slate-400" />
</div>
)}
</div>
<Button
type="button"
variant="outline"
onClick={() => setIsImagePickerOpen(true)}
className="rounded-xl border-[#063e8e]/15"
>
<ImagePlus className="mr-2 h-4 w-4" />
{form.file_id ? "Đổi ảnh" : "Chọn ảnh"}
</Button>
{form.file_id && (
<span className="text-xs text-green-600">✓ Đã chọn ảnh</span>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Tên quảng cáo *</Label>
<Input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="VD: Quảng cáo sidebar 1"
className="rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Alt text</Label>
<Input
value={form.alt}
onChange={(e) => setForm((prev) => ({ ...prev, alt: e.target.value }))}
placeholder="Mô tả ảnh (cho SEO)"
className="rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Đường link *</Label>
<Input
value={form.link}
onChange={(e) => setForm((prev) => ({ ...prev, link: e.target.value }))}
placeholder="https://example.com"
className="rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Trạng thái *</Label>
<Select
value={form.status}
onValueChange={(v) =>
setForm((prev) => ({ ...prev, status: v as "ACTIVE" | "INACTIVE" }))
}
>
<SelectTrigger className="rounded-xl border-[#063e8e]/15">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACTIVE">Hiển thị</SelectItem>
<SelectItem value="INACTIVE">Ẩn</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Thứ tự</Label>
<Input
type="number"
min={0}
value={form.sort_order}
onChange={(e) =>
setForm((prev) => ({
...prev,
sort_order: parseInt(e.target.value, 10) || 0,
}))
}
className="rounded-xl border-[#063e8e]/15"
/>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
className="rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleSave}
disabled={
!form.name || !form.file_id || !form.link ||
createMutation.isPending || updateMutation.isPending
}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{(createMutation.isPending || updateMutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{selectedAd ? "Cập nhật" : "Tạo quảng cáo"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Image Picker */}
<AdminImagePicker
open={isImagePickerOpen}
selectedId={form.file_id || null}
onOpenChange={setIsImagePickerOpen}
onSelect={handleSelectImage}
/>
{/* Delete Confirmation */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-md rounded-3xl border-[#063e8e]/15">
<DialogHeader>
<DialogTitle className="text-xl text-[#163b73]">
Xác nhận xóa quảng cáo
</DialogTitle>
<DialogDescription>
Bạn có chắc chắn muốn xóa quảng cáo{" "}
<strong>{adToDelete?.name}</strong>? Hành động này không thể hoàn tác.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
className="rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleConfirmDelete}
disabled={deleteMutation.isPending}
className="rounded-xl bg-red-600 text-white hover:bg-red-700"
>
{deleteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Xóa
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
import { AdvertisementList } from "./advertisement-list";
export default function AdvertisementsPage() {
const canRead = usePermission("advertisements", "read");
if (!canRead) {
return <NoPermissionMessage />;
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-[#163b73]">Quản lý Quảng cáo</h1>
<p className="mt-1 text-sm text-slate-600">
Quản lý quảng cáo sidebar (vuông) và banner ngang trên trang chủ
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="square" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2 rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] p-1">
<TabsTrigger
value="square"
className="rounded-xl data-[state=active]:bg-[#063e8e] data-[state=active]:text-white"
>
Quảng cáo vuông (Sidebar)
</TabsTrigger>
<TabsTrigger
value="horizontal"
className="rounded-xl data-[state=active]:bg-[#063e8e] data-[state=active]:text-white"
>
Quảng cáo ngang (Banner)
</TabsTrigger>
</TabsList>
<TabsContent value="square" className="mt-6">
<AdvertisementList
type="square"
title="Quảng cáo vuông (Sidebar)"
description="Hiển thị ở sidebar các trang. Tỉ lệ ảnh khuyến nghị 16:10"
previewAspect="16 / 10"
note="Website chỉ hiển thị tối đa 5 quảng cáo vuông (theo sort_order). Các quảng cáo có thứ tự lớn hơn hoặc trạng thái 'Ẩn' sẽ không hiện trên trang."
/>
</TabsContent>
<TabsContent value="horizontal" className="mt-6">
<AdvertisementList
type="horizontal"
title="Quảng cáo ngang (Banner)"
description="Hiển thị banner full-width giữa các section. Tỉ lệ ảnh khuyến nghị 1600:200"
previewAspect="1600 / 200"
note="Website chỉ hiển thị 1 quảng cáo ngang duy nhất (quảng cáo có sort_order nhỏ nhất và trạng thái 'Hiển thị'). Tất cả vị trí banner trên trang sẽ dùng chung 1 quảng cáo này."
/>
</TabsContent>
</Tabs>
</div>
);
}
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Lock, Eye, EyeOff, KeyRound, CheckCircle2, AlertCircle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePutApiV10UserChangePassword } from "@/api/endpoints/user";
import useAuthStore from "@/store/useAuthStore";
import { logoutAdmin } from "@/lib/auth/admin-auth";
export default function ChangePasswordPage() {
const router = useRouter();
const appUser = useAuthStore((state) => state.appUser);
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showOld, setShowOld] = useState(false);
const [showNew, setShowNew] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [done, setDone] = useState(false);
const changePasswordMutation = usePutApiV10UserChangePassword();
const validate = (): string | null => {
if (!oldPassword.trim()) return "Vui lòng nhập mật khẩu cũ";
if (newPassword.length < 6) return "Mật khẩu mới phải có ít nhất 6 ký tự";
if (newPassword === oldPassword) return "Mật khẩu mới phải khác mật khẩu cũ";
if (newPassword !== confirmPassword) return "Xác nhận mật khẩu không khớp";
return null;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const error = validate();
if (error) {
toast.error(error);
return;
}
try {
await changePasswordMutation.mutateAsync({
data: {
oldPassword,
newPassword,
},
});
setDone(true);
toast.success("Đổi mật khẩu thành công!");
// Update store để bỏ must_change_password
useAuthStore.getState().setAppUser({
...appUser,
must_change_password: false,
} as typeof appUser);
// Sau 2s, logout để user đăng nhập lại bằng mật khẩu mới
setTimeout(async () => {
await logoutAdmin({ silent: true, redirectToLogin: true });
}, 2000);
} catch (err: unknown) {
const e = err as { message?: string };
toast.error(e?.message || "Đổi mật khẩu thất bại");
}
};
if (done) {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#063e8e]/5 to-[#f8fbff] p-4">
<div className="w-full max-w-md rounded-3xl border border-[#063e8e]/10 bg-white p-8 text-center shadow-xl">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-[#163b73]">Đổi mật khẩu thành công</h1>
<p className="mt-2 text-sm text-slate-600">
Mật khẩu của bạn đã được cập nhật. Bạn sẽ được chuyển về trang đăng nhập
để đăng nhập lại bằng mật khẩu mới.
</p>
<div className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin" />
Đang chuyển hướng...
</div>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#063e8e]/5 to-[#f8fbff] p-4">
<div className="w-full max-w-md rounded-3xl border border-[#063e8e]/10 bg-white p-8 shadow-xl">
{/* Header */}
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100">
<KeyRound className="h-8 w-8 text-amber-600" />
</div>
<h1 className="text-2xl font-bold text-[#163b73]">Đổi mật khẩu</h1>
<p className="mt-2 text-sm text-slate-600">
Đây là lần đầu bạn đăng nhập. Vui lòng đổi mật khẩu để tiếp tục.
</p>
</div>
{/* Warning */}
<div className="mb-6 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<p className="text-sm text-amber-800">
Mật khẩu mặc định không được phép sử dụng. Bạn cần tạo mật khẩu mới
để bảo vệ tài khoản.
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu hiện tại *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showOld ? "text" : "password"}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="Nhập mật khẩu hiện tại"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
autoFocus
/>
<button
type="button"
onClick={() => setShowOld(!showOld)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showOld ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu mới *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showNew ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Ít nhất 6 ký tự"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowNew(!showNew)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showNew ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Xác nhận mật khẩu mới *</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type={showConfirm ? "text" : "password"}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Nhập lại mật khẩu mới"
className="h-11 rounded-xl border-[#063e8e]/15 pl-10 pr-10"
/>
<button
type="button"
onClick={() => setShowConfirm(!showConfirm)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showConfirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-red-600">Mật khẩu xác nhận không khớp</p>
)}
</div>
<Button
type="submit"
disabled={
!oldPassword ||
!newPassword ||
!confirmPassword ||
newPassword !== confirmPassword ||
changePasswordMutation.isPending
}
className="h-11 w-full rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{changePasswordMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Đổi mật khẩu
</Button>
</form>
</div>
</div>
);
}
......@@ -16,6 +16,7 @@ import {
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { useGetApiV10Logo } from "@/api/endpoints/logo";
import { usePostApiV10AuthForgotPasswordRequest } from "@/api/endpoints/authentication";
import type { Logo } from "@/api/models/logo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
......@@ -26,8 +27,7 @@ import links, { resolveUploadUrl } from "@/links";
import { loginAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
type AuthMode = "login" | "forgot" | "reset";
type ResetStep = "request" | "verify" | "password" | "done";
type AuthMode = "login" | "forgot";
type ApiEnvelope<T = unknown> = {
responseData?: T;
......@@ -46,11 +46,6 @@ type LogoListEnvelope = {
};
};
type VerifyOtpPayload = {
reset_token?: string;
expires_in?: number;
};
type ErrorResponse = {
message?: string;
error?: {
......@@ -61,7 +56,7 @@ type ErrorResponse = {
};
};
const DEFAULT_REDIRECT = "/admin/base-config";
const DEFAULT_REDIRECT = "/admin";
const ADMIN_BLUE = "#063e8e";
const authFieldClassName =
"h-11 rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-400 shadow-sm focus-visible:ring-[#063e8e]/30";
......@@ -72,7 +67,8 @@ function normalizeRedirectPath(redirect: string | null) {
if (
!redirect ||
!redirect.startsWith("/admin") ||
redirect === "/admin/login"
redirect === "/admin/login" ||
redirect === "/admin/change-password"
) {
return DEFAULT_REDIRECT;
}
......@@ -100,30 +96,6 @@ function getAuthErrorMessage(error: unknown, fallback: string) {
);
}
async function postAuthJson<TResponse, TBody>(path: string, body: TBody) {
const response = await fetch(`${links.apiEndpoint}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(body),
});
const data = (await response.json().catch(() => ({}))) as TResponse &
ErrorResponse;
if (!response.ok) {
throw {
response: {
data,
},
};
}
return data as TResponse;
}
function AuthShell({
mode,
children,
......@@ -151,16 +123,12 @@ function AuthShell({
const title =
mode === "login"
? "Đăng nhập quản trị"
: mode === "forgot"
? "Khôi phục mật khẩu"
: "Đặt lại mật khẩu";
: "Khôi phục mật khẩu";
const description =
mode === "login"
? "Truy cập khu vực quản trị nội dung VCCI News."
: mode === "forgot"
? "Xác thực email quản trị để nhận mã OTP."
: "Nhập mã OTP để tạo mật khẩu mới cho tài khoản.";
: "Gửi yêu cầu reset mật khẩu cho ban quản trị.";
return (
<div className="min-h-screen bg-[#f6f9ff] px-4 py-8 text-gray-700">
......@@ -346,14 +314,11 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<ResetStep>("request");
const [otp, setOtp] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [resetToken, setResetToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [resetMessage, setResetMessage] = useState<string | null>(null);
const [resetError, setResetError] = useState<string | null>(null);
// Forgot password state
const [forgotNote, setForgotNote] = useState("");
const [forgotLoading, setForgotLoading] = useState(false);
const [forgotError, setForgotError] = useState<string | null>(null);
const [forgotMessage, setForgotMessage] = useState<string | null>(null);
useEffect(() => {
if (!rememberState?.remember) return;
......@@ -366,7 +331,13 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
useEffect(() => {
if (!hasHydrated || !isLoggedIn) return;
// Nếu user phải đổi mật khẩu → luôn redirect sang change-password
const currentUser = useAuthStore.getState().appUser;
if (currentUser?.must_change_password) {
router.replace("/admin/change-password");
} else {
router.replace(redirect);
}
}, [hasHydrated, isLoggedIn, redirect, router]);
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
......@@ -375,15 +346,21 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
setLoginLoading(true);
try {
await loginAdmin(email.trim(), password, { persistSession: remember });
const loginData = await loginAdmin(email.trim(), password, { persistSession: remember });
setAppUserRemember(
remember ? email.trim() : "",
remember ? password : "",
remember,
);
// Nếu BE báo phải đổi mật khẩu → redirect sang trang đổi mật khẩu
if (loginData?.must_change_password) {
toast.success("Đăng nhập thành công. Vui lòng đổi mật khẩu để tiếp tục.");
router.replace("/admin/change-password");
} else {
toast.success("Đăng nhập quản trị thành công");
router.replace(redirect);
}
} catch (error) {
setLoginError(
getAuthErrorMessage(error, "Đăng nhập thất bại. Vui lòng thử lại."),
......@@ -393,121 +370,54 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
}
};
const handleSendOtp = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setResetError(null);
setResetMessage(null);
setResetLoading(true);
try {
await postAuthJson<ApiEnvelope, { email: string }>(
"/auth/forgot-password/send-otp",
{
email: email.trim(),
},
const forgotMutation = usePostApiV10AuthForgotPasswordRequest({
mutation: {
onSuccess: (response: any) => {
const data = response?.responseData ?? response?.data?.responseData;
setForgotMessage(
data?.message ||
"Yêu cầu của bạn đã được ghi nhận. Ban quản trị sẽ liên hệ với bạn sớm.",
);
setResetStep("verify");
setMode("reset");
setResetMessage("M? OTP d? du?c g?i d?n email qu?n tr?.");
} catch (error) {
setResetError(
getAuthErrorMessage(error, "Kh?ng th? g?i m? OTP. Vui l?ng th? l?i."),
setForgotNote("");
toast.success("Đã gửi yêu cầu reset mật khẩu");
},
onError: (error: any) => {
setForgotError(
getAuthErrorMessage(error, "Không thể gửi yêu cầu. Vui lòng thử lại."),
);
} finally {
setResetLoading(false);
}
};
const handleVerifyOtp = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setResetError(null);
setResetMessage(null);
setResetLoading(true);
try {
const response = await postAuthJson<
ApiEnvelope<VerifyOtpPayload>,
{ email: string; otp: string }
>("/auth/forgot-password/verify-otp", {
email: email.trim(),
otp: otp.trim(),
},
onSettled: () => {
setForgotLoading(false);
},
},
});
const payload = getResponseData<VerifyOtpPayload>(response);
if (!payload?.reset_token) {
throw new Error("Kh?ng nh?n du?c m? d?t l?i m?t kh?u t? API.");
}
setResetToken(payload.reset_token);
setResetStep("password");
setResetMessage("OTP hợp lệ. Bạn có thể tạo mật khẩu mới.");
} catch (error) {
setResetError(
getAuthErrorMessage(error, "OTP kh?ng h?p l? ho?c d? h?t h?n."),
);
} finally {
setResetLoading(false);
}
};
const handleResetPassword = async (event: FormEvent<HTMLFormElement>) => {
const handleForgotRequest = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setResetError(null);
setResetMessage(null);
setForgotError(null);
setForgotMessage(null);
if (newPassword.length < 6) {
setResetError("Mật khẩu mới phải có ít nhất 6 ký tự.");
if (!email.trim()) {
setForgotError("Vui lòng nhập email quản trị.");
return;
}
if (newPassword !== confirmPassword) {
setResetError("Mật khẩu xác nhận chưa khớp.");
return;
}
setResetLoading(true);
try {
await postAuthJson<
ApiEnvelope,
{ reset_token: string; new_password: string }
>("/auth/forgot-password/reset", {
reset_token: resetToken,
new_password: newPassword,
setForgotLoading(true);
forgotMutation.mutate({
data: { email: email.trim(), note: forgotNote.trim() || undefined },
});
setResetStep("done");
setResetMessage(
"Đặt lại mật khẩu thành công. Bạn có thể đăng nhập bằng mật khẩu mới.",
);
setPassword("");
setNewPassword("");
setConfirmPassword("");
toast.success("Đặt lại mật khẩu thành công");
} catch (error) {
setResetError(
getAuthErrorMessage(
error,
"Không thể đặt lại mật khẩu. Vui lòng thử lại.",
),
);
} finally {
setResetLoading(false);
}
};
const switchToLogin = () => {
setMode("login");
setResetError(null);
setResetMessage(null);
setForgotError(null);
setForgotMessage(null);
};
const switchToForgot = () => {
setMode("forgot");
setResetStep("request");
setResetError(null);
setResetMessage(null);
setForgotError(null);
setForgotMessage(null);
};
if (!hasHydrated) {
......@@ -602,14 +512,19 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
) : null}
{mode === "forgot" ? (
<form className="space-y-5" onSubmit={handleSendOtp}>
{resetError ? (
<InlineMessage type="error" message={resetError} />
<form className="space-y-5" onSubmit={handleForgotRequest}>
{forgotError ? (
<InlineMessage type="error" message={forgotError} />
) : null}
{resetMessage ? (
<InlineMessage type="success" message={resetMessage} />
{forgotMessage ? (
<InlineMessage type="success" message={forgotMessage} />
) : null}
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Nếu bạn quên mật khẩu, vui lòng nhập email và ghi chú yêu cầu.
Ban quản trị sẽ liên hệ để cấp lại mật khẩu cho bạn.
</div>
<div className="space-y-2">
<Label htmlFor="forgot-email" className="text-gray-700">
Email quản trị
......@@ -629,180 +544,45 @@ function AdminLoginPageContent({ redirect }: { redirect: string }) {
</div>
</div>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
>
{resetLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang gửi OTP...
</>
) : (
"Gửi mã OTP"
)}
</Button>
<Button
type="button"
variant="ghost"
onClick={switchToLogin}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
<ArrowLeft className="h-4 w-4" />
Quay lại đăng nhập
</Button>
</form>
) : null}
{mode === "reset" ? (
<div className="space-y-6">
<div className="grid grid-cols-3 gap-2">
{[
["verify", "OTP"],
["password", "Mật khẩu"],
["done", "Hoàn tất"],
].map(([step, label]) => {
const stepIndex = ["verify", "password", "done"].indexOf(step);
const currentIndex = ["verify", "password", "done"].indexOf(
resetStep,
);
const active = currentIndex >= stepIndex;
return (
<div
key={step}
className={
active
? "rounded-xl border border-[#063e8e]/20 bg-[#edf4ff] px-3 py-2 text-center text-xs font-semibold text-[#063e8e]"
: "rounded-xl border border-gray-200 bg-white px-3 py-2 text-center text-xs font-semibold text-gray-500"
}
>
{label}
</div>
);
})}
</div>
{resetError ? (
<InlineMessage type="error" message={resetError} />
) : null}
{resetMessage ? (
<InlineMessage type="success" message={resetMessage} />
) : null}
{resetStep === "verify" ? (
<form className="space-y-5" onSubmit={handleVerifyOtp}>
<div className="space-y-2">
<Label htmlFor="otp" className="text-gray-700">
M? OTP
<Label htmlFor="forgot-note" className="text-gray-700">
Ghi chú (tùy chọn)
</Label>
<Input
id="otp"
inputMode="numeric"
autoComplete="one-time-code"
value={otp}
onChange={(event) =>
setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))
}
placeholder="Nhập 6 chữ số"
className={`${authFieldClassName} text-center text-lg font-semibold tracking-[0.35em]`}
required
/>
</div>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
>
{resetLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang xác thực...
</>
) : (
"Xác thực OTP"
)}
</Button>
<Button
type="button"
variant="ghost"
onClick={switchToForgot}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
G?i l?i m? OTP
</Button>
</form>
) : null}
{resetStep === "password" ? (
<form className="space-y-5" onSubmit={handleResetPassword}>
<div className="space-y-2">
<Label htmlFor="new-password" className="text-gray-700">
Mật khẩu mới
</Label>
<PasswordInput
id="new-password"
value={newPassword}
onChange={setNewPassword}
placeholder="Tối thiểu 6 ký tự"
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-gray-700">
Xác nhận mật khẩu
</Label>
<PasswordInput
id="confirm-password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="Nhập lại mật khẩu mới"
autoComplete="new-password"
<textarea
id="forgot-note"
value={forgotNote}
onChange={(event) => setForgotNote(event.target.value)}
placeholder="VD: Tên của bạn, lý do quên mật khẩu, số điện thoại liên hệ..."
rows={3}
className={`${authFieldClassName} resize-none`}
/>
</div>
<Button
type="submit"
className={authButtonClassName}
disabled={resetLoading}
disabled={forgotLoading}
>
{resetLoading ? (
{forgotLoading ? (
<>
<LoaderCircle className="h-4 w-4 animate-spin" />
Đang cập nhật...
Đang gửi yêu cầu...
</>
) : (
"Đặt lại mật khẩu"
"Gửi yêu cầu reset mật khẩu"
)}
</Button>
</form>
) : null}
{resetStep === "done" ? (
<div className="space-y-5">
<div className="rounded-2xl border border-[#063e8e]/15 bg-[#f8fbff] p-5 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-[#063e8e]" />
<div className="mt-3 text-base font-semibold text-gray-900">
M?t kh?u d? du?c c?p nh?t
</div>
<p className="mt-2 text-sm leading-6 text-gray-700">
Quay lại màn đăng nhập để vào khu vực quản trị bằng mật khẩu
mới.
</p>
</div>
<Button
type="button"
className={authButtonClassName}
variant="ghost"
onClick={switchToLogin}
className="h-10 w-full rounded-xl text-gray-700 hover:bg-[#edf4ff] hover:text-[#063e8e]"
>
Đăng nhập ngay
<ArrowLeft className="h-4 w-4" />
Quay lại đăng nhập
</Button>
</div>
) : null}
</div>
</form>
) : null}
</AuthShell>
);
......
"use client";
import Link from "next/link";
import { usePermission } from "@/hooks/usePermission";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
export default function NoPermissionPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff]">
{/* Header */}
<div className="border-b border-[#063e8e]/10 bg-white/80 px-6 py-4 backdrop-blur-sm">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#063e8e]">
<svg
className="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
</div>
<div>
<div className="text-sm font-semibold text-[#063e8e]">VCCI News Admin</div>
<div className="text-xs text-slate-500">Trang quản trị website</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="mx-auto max-w-2xl px-4 py-16">
<NoPermissionMessage />
</div>
{/* Footer */}
<div className="border-t border-[#063e8e]/10 bg-white/50 px-6 py-4 text-center">
<p className="text-sm text-slate-500">© 2026 VCCI HCM</p>
</div>
</div>
);
}
import { redirect } from 'next/navigation';
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import useAuthStore from "@/store/useAuthStore";
import { usePermission } from "@/hooks/usePermission";
import { Loader2, ShieldX, Mail, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
// Thứ tự ưu tiên các trang admin mặc định
const ADMIN_LANDING_ROUTES = [
{ path: "/admin/dashboard", permission: "dashboard:read" as const },
{ path: "/admin/news", permission: "posts:read" as const },
{ path: "/admin/base-config", permission: "settings:read" as const },
{ path: "/admin/users", permission: "users:read" as const },
{ path: "/admin/password-reset-requests", permission: "users:read" as const },
{ path: "/admin/roles", permission: "roles:read" as const },
{ path: "/admin/advertisements", permission: "advertisements:read" as const },
{ path: "/admin/media", permission: "files:read" as const },
{ path: "/admin/tags", permission: "tags:read" as const },
{ path: "/admin/videos", permission: "videos:read" as const },
{ path: "/admin/members", permission: "members:read" as const },
{ path: "/admin/contact-management", permission: "contact:read" as const },
];
export default function AdminPage() {
redirect('/admin/base-config');
const router = useRouter();
const appUser = useAuthStore((state) => state.appUser);
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const isLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
const hasDashboard = usePermission("dashboard", "read");
const hasPosts = usePermission("posts", "read");
const hasSettings = usePermission("settings", "read");
const hasUsers = usePermission("users", "read");
const hasRoles = usePermission("roles", "read");
const hasAds = usePermission("advertisements", "read");
const hasFiles = usePermission("files", "read");
const hasTags = usePermission("tags", "read");
const hasVideos = usePermission("videos", "read");
const hasMembers = usePermission("members", "read");
const hasContact = usePermission("contact", "read");
const permissionMap: Record<string, boolean> = {
"dashboard:read": hasDashboard,
"posts:read": hasPosts,
"settings:read": hasSettings,
"users:read": hasUsers,
"roles:read": hasRoles,
"advertisements:read": hasAds,
"files:read": hasFiles,
"tags:read": hasTags,
"videos:read": hasVideos,
"members:read": hasMembers,
"contact:read": hasContact,
};
useEffect(() => {
if (!hasHydrated || !isLoggedIn) return;
if (appUser?.must_change_password) return; // AuthGuard sẽ xử lý
// Tìm trang admin đầu tiên user có quyền
const firstAllowed = ADMIN_LANDING_ROUTES.find(
(route) => permissionMap[route.permission],
);
if (firstAllowed) {
router.replace(firstAllowed.path);
}
// Nếu không có quyền gì → stay on /admin, render no-access message
}, [hasHydrated, isLoggedIn, appUser, router]);
// Loading
if (!hasHydrated || !isLoggedIn) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Nếu phải đổi mật khẩu → AuthGuard sẽ redirect, tạm render loading
if (appUser?.must_change_password) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Kiểm tra có quyền gì không
const hasAnyPermission = ADMIN_LANDING_ROUTES.some(
(route) => permissionMap[route.permission],
);
// Nếu có quyền → loading (đang redirect)
if (hasAnyPermission) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#f8fbff]">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
);
}
// Không có quyền gì → hiển thị thông báo
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] px-4">
<div className="w-full max-w-2xl rounded-3xl border border-[#063e8e]/10 bg-white p-8 text-center shadow-xl md:p-12">
{/* Icon */}
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-amber-100">
<ShieldX className="h-10 w-10 text-amber-600" />
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-[#163b73] md:text-3xl">
Tài khoản chưa được cấp quyền
</h1>
{/* Message */}
<p className="mx-auto mt-4 max-w-md text-sm text-slate-600 md:text-base">
Tài khoản của bạn hiện tại chưa có quyền quản trị chức năng nào.
Vui lòng liên hệ ban quản trị website hoặc bên kỹ thuật để được hỗ trợ.
</p>
{/* User info */}
<div className="mt-6 rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] px-6 py-4 text-left">
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-2">
<div>
<span className="text-slate-500">Email:</span>{" "}
<span className="font-medium text-[#163b73]">{appUser?.email}</span>
</div>
<div>
<span className="text-slate-500">Vai trò:</span>{" "}
<span className="font-medium text-[#163b73]">
{appUser?.roles?.length ? appUser.roles.join(", ") : "Chưa có"}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
"use client";
import React, { useState } from "react";
import {
Loader2,
CheckCircle,
XCircle,
Clock,
Mail,
KeyRound,
ChevronLeft,
ChevronRight,
Eye,
EyeOff,
Copy,
CheckCheck,
} from "lucide-react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { usePermission } from "@/hooks/usePermission";
import { NoPermissionMessage } from "@/components/shared/permission-gate";
import {
useGetApiV10PasswordResetRequest,
usePostApiV10PasswordResetRequestIdResolve,
usePostApiV10PasswordResetRequestIdReject,
getGetApiV10PasswordResetRequestQueryKey,
} from "@/api/endpoints/password-reset-request";
interface PasswordResetRequest {
id: string;
email: string;
note?: string | null;
status: "PENDING" | "RESOLVED" | "REJECTED";
resolved_by?: string | null;
resolved_at?: string | null;
resolve_note?: string | null;
created_at?: string | null;
updated_at?: string | null;
resolved_by_user?: {
id: string;
email: string;
first_name: string | null;
last_name: string | null;
} | null;
}
interface ListResponse {
rows: PasswordResetRequest[];
count: number;
page: number;
pageSize: number;
totalPages: number;
}
const PAGE_SIZE = 10;
const DEFAULT_NEW_PASSWORD = "vcci@2026";
function formatDate(dateStr?: string | null) {
if (!dateStr) return "-";
try {
return new Date(dateStr).toLocaleString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "-";
}
}
export default function PasswordResetRequestsPage() {
const canRead = usePermission("users", "read");
const canWrite = usePermission("users", "write");
const [currentPage, setCurrentPage] = useState(1);
const [filterStatus, setFilterStatus] = useState<"all" | "PENDING" | "RESOLVED" | "REJECTED">("PENDING");
// Resolve dialog
const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false);
const [selectedRequest, setSelectedRequest] = useState<PasswordResetRequest | null>(null);
const [newPassword, setNewPassword] = useState(DEFAULT_NEW_PASSWORD);
const [resolveNote, setResolveNote] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isResolving, setIsResolving] = useState(false);
const [copied, setCopied] = useState(false);
// Reject dialog
const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const [isRejecting, setIsRejecting] = useState(false);
const queryClient = useQueryClient();
// Query list
const { data: queryData, isLoading } = useGetApiV10PasswordResetRequest(
{
page: currentPage,
pageSize: PAGE_SIZE,
status: filterStatus === "all" ? undefined : filterStatus,
sortField: "created_at",
sortOrder: "desc",
},
{
query: { enabled: canRead },
},
);
const data = (queryData as any)?.responseData as ListResponse | null;
const rows = data?.rows || [];
const total = data?.count || 0;
const totalPages = data?.totalPages || 1;
// Mutations
const resolveMutation = usePostApiV10PasswordResetRequestIdResolve({
mutation: {
onSuccess: () => {
toast.success("Đã reset mật khẩu thành công!");
setIsResolveDialogOpen(false);
queryClient.invalidateQueries({
queryKey: getGetApiV10PasswordResetRequestQueryKey(),
});
},
onError: (error: any) => {
toast.error(error?.message || "Xử lý yêu cầu thất bại");
},
onSettled: () => setIsResolving(false),
},
});
const rejectMutation = usePostApiV10PasswordResetRequestIdReject({
mutation: {
onSuccess: () => {
toast.success("Đã từ chối yêu cầu");
setIsRejectDialogOpen(false);
queryClient.invalidateQueries({
queryKey: getGetApiV10PasswordResetRequestQueryKey(),
});
},
onError: (error: any) => {
toast.error(error?.message || "Xử lý yêu cầu thất bại");
},
onSettled: () => setIsRejecting(false),
},
});
const handleOpenResolve = (req: PasswordResetRequest) => {
setSelectedRequest(req);
setNewPassword(DEFAULT_NEW_PASSWORD);
setResolveNote("");
setShowPassword(false);
setIsResolveDialogOpen(true);
};
const handleOpenReject = (req: PasswordResetRequest) => {
setSelectedRequest(req);
setRejectNote("");
setIsRejectDialogOpen(true);
};
const handleConfirmResolve = async () => {
if (!selectedRequest) return;
if (!newPassword.trim() || newPassword.length < 6) {
toast.error("Mật khẩu mới phải có ít nhất 6 ký tự");
return;
}
setIsResolving(true);
resolveMutation.mutate({
id: selectedRequest.id,
data: {
newPassword: newPassword.trim(),
resolveNote: resolveNote.trim() || undefined,
},
});
};
const handleConfirmReject = async () => {
if (!selectedRequest) return;
setIsRejecting(true);
rejectMutation.mutate({
id: selectedRequest.id,
data: {
resolveNote: rejectNote.trim() || undefined,
},
});
};
const handleCopyPassword = () => {
navigator.clipboard.writeText(newPassword);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
if (!canRead) {
return <NoPermissionMessage />;
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-[#163b73]">Yêu cầu reset mật khẩu</h1>
<p className="mt-1 text-sm text-slate-600">
Danh sách yêu cầu reset mật khẩu từ user quên mật khẩu ({total} yêu cầu)
</p>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Label className="text-sm text-slate-600">Trạng thái:</Label>
<Select
value={filterStatus}
onValueChange={(v) => {
setFilterStatus(v as typeof filterStatus);
setCurrentPage(1);
}}
>
<SelectTrigger className="w-[180px] rounded-xl border-[#063e8e]/15">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PENDING">Chờ xử lý</SelectItem>
<SelectItem value="RESOLVED">Đã xử lý</SelectItem>
<SelectItem value="REJECTED">Từ chối</SelectItem>
<SelectItem value="all">Tất cả</SelectItem>
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-2xl border border-[#063e8e]/10 bg-white overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="w-10 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
STT
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Email
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Ghi chú
</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
Trạng thái
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Thời gian
</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">
Người xử lý
</TableHead>
<TableHead className="w-32 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">
Thao tác
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-slate-500">
Không có yêu cầu nào
</TableCell>
</TableRow>
) : (
rows.map((req, index) => (
<TableRow
key={req.id}
className="border-b border-[#063e8e]/5 hover:bg-[#063e8e]/2"
>
<TableCell className="text-center text-slate-600 whitespace-nowrap">
{(currentPage - 1) * PAGE_SIZE + index + 1}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-slate-400 shrink-0" />
<span className="text-sm font-medium text-[#163b73]">
{req.email}
</span>
</div>
</TableCell>
<TableCell>
<p className="text-sm text-slate-600 truncate max-w-[200px]">
{req.note || "-"}
</p>
</TableCell>
<TableCell className="text-center">
{req.status === "PENDING" && (
<Badge variant="outline" className="text-sm border-amber-200 bg-amber-50 text-amber-700 whitespace-nowrap">
<Clock className="mr-1 h-4 w-4" />
Chờ xử lý
</Badge>
)}
{req.status === "RESOLVED" && (
<Badge variant="outline" className="text-sm border-green-200 bg-green-50 text-green-700 whitespace-nowrap">
<CheckCircle className="mr-1 h-4 w-4" />
Đã xử lý
</Badge>
)}
{req.status === "REJECTED" && (
<Badge variant="outline" className="text-sm border-red-200 bg-red-50 text-red-700 whitespace-nowrap">
<XCircle className="mr-1 h-4 w-4" />
Từ chối
</Badge>
)}
</TableCell>
<TableCell>
<p className="text-sm text-slate-500 whitespace-nowrap">
{formatDate(req.created_at)}
</p>
</TableCell>
<TableCell>
{req.resolved_by_user ? (
<span className="text-sm text-slate-600">
{req.resolved_by_user.email}
</span>
) : (
<span className="text-sm text-slate-400">-</span>
)}
</TableCell>
<TableCell>
{req.status === "PENDING" && canWrite ? (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenResolve(req)}
className="h-9 rounded-lg text-green-600 hover:bg-green-50"
title="Reset mật khẩu"
>
<KeyRound className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenReject(req)}
className="h-9 rounded-lg text-red-600 hover:bg-red-50"
title="Từ chối"
>
<XCircle className="h-4 w-4" />
</Button>
</div>
) : (
<span className="text-sm text-slate-400">-</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Hiển thị {(currentPage - 1) * PAGE_SIZE + 1} -{" "}
{Math.min(currentPage * PAGE_SIZE, total)} trong {total} yêu cầu
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || isLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm font-medium text-[#163b73]">
Trang {currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={currentPage >= totalPages}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Resolve Dialog */}
<Dialog open={isResolveDialogOpen} onOpenChange={setIsResolveDialogOpen}>
<DialogContent className="max-w-lg rounded-3xl border-[#063e8e]/15">
<DialogHeader>
<DialogTitle className="text-xl text-[#163b73]">Reset mật khẩu</DialogTitle>
<DialogDescription>
Reset mật khẩu cho email{" "}
<strong className="text-[#063e8e]">{selectedRequest?.email}</strong>.
User sẽ phải đổi mật khẩu khi đăng nhập lần tiếp theo.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Mật khẩu mới</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Ít nhất 6 ký tự"
className="h-10 rounded-xl border-[#063e8e]/15 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
type="button"
variant="outline"
onClick={handleCopyPassword}
className="h-10 rounded-xl border-[#063e8e]/15"
title="Copy mật khẩu"
>
{copied ? <CheckCheck className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-slate-500">
Mật khẩu mặc định: <code className="rounded bg-slate-100 px-1">{DEFAULT_NEW_PASSWORD}</code>.
Bạn có thể đổi sang mật khẩu tùy chỉnh.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Ghi chú (tùy chọn)</Label>
<Textarea
value={resolveNote}
onChange={(e) => setResolveNote(e.target.value)}
placeholder="VD: Đã gọi điện xác nhận, đã gửi mật khẩu qua Zalo..."
rows={3}
className="rounded-xl border-[#063e8e]/15 resize-none"
/>
</div>
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Lưu ý: Sau khi reset, bạn cần gửi mật khẩu mới cho user qua kênh khác
(điện thoại, Zalo, email cá nhân, etc.).
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsResolveDialogOpen(false)}
className="h-10 rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleConfirmResolve}
disabled={isResolving || newPassword.length < 6}
className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{isResolving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Reset mật khẩu
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Dialog */}
<Dialog open={isRejectDialogOpen} onOpenChange={setIsRejectDialogOpen}>
<DialogContent className="max-w-md rounded-3xl border-[#063e8e]/15">
<DialogHeader>
<DialogTitle className="text-xl text-[#163b73]">Từ chối yêu cầu</DialogTitle>
<DialogDescription>
Từ chối yêu cầu reset mật khẩu cho email{" "}
<strong className="text-[#063e8e]">{selectedRequest?.email}</strong>?
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label className="text-sm font-medium">Lý do từ chối (tùy chọn)</Label>
<Textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="VD: Email không tồn tại trong hệ thống, không thể xác minh danh tính..."
rows={3}
className="rounded-xl border-[#063e8e]/15 resize-none"
/>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsRejectDialogOpen(false)}
className="h-10 rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleConfirmReject}
disabled={isRejecting}
className="h-10 rounded-xl bg-red-600 text-white hover:bg-red-700"
>
{isRejecting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Từ chối
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
"use client";
import React, { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Edit,
Trash2,
Shield,
Users,
Check,
Loader2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { NoPermissionMessage, PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
// API imports
import {
useGetApiV10Role,
usePostApiV10Role,
usePutApiV10RoleId,
useDeleteApiV10RoleId,
} from "@/api/endpoints/role";
// Types
interface Role {
id: string;
name: string;
description?: string;
permissions: string[];
user_count?: number;
created_at?: string;
updated_at?: string;
}
// All available permissions
const ALL_PERMISSIONS = [
{ resource: "settings", actions: ["read", "write"], label: "Cấu hình chung" },
{ resource: "categories", actions: ["read", "write", "delete"], label: "Danh mục" },
{ resource: "posts", actions: ["read", "write", "delete", "publish"], label: "Bài viết" },
{ resource: "tags", actions: ["read", "write", "delete"], label: "Tags" },
{ resource: "videos", actions: ["read", "write", "delete"], label: "Videos" },
{ resource: "newsletter", actions: ["read", "delete"], label: "Newsletter" },
{ resource: "files", actions: ["read", "write", "delete"], label: "Files/Media" },
{ resource: "advertisements", actions: ["read", "write", "delete"], label: "Quảng cáo" },
{ resource: "roles", actions: ["read", "write", "delete"], label: "Roles (Quản lý vai trò)" },
{ resource: "users", actions: ["read", "write", "delete"], label: "Users (Quản lý người dùng)" },
];
// System roles không cho phép xóa
const SYSTEM_ROLES = ["system_admin", "admin", "new"];
export default function RolesPage() {
const canReadRoles = usePermission("roles", "read");
const canWriteRoles = usePermission("roles", "write");
const canDeleteRoles = usePermission("roles", "delete");
const queryClient = useQueryClient();
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editForm, setEditForm] = useState<{
name: string;
description: string;
permissions: string[];
}>({ name: "", description: "", permissions: [] });
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [roleToDelete, setRoleToDelete] = useState<Role | null>(null);
const [currentPage, setCurrentPage] = useState(1);
// Fetch roles
const { data: rolesData, isLoading } = useGetApiV10Role({
page: currentPage,
pageSize: 10,
});
// Mutations
const createRoleMutation = usePostApiV10Role();
const updateRoleMutation = usePutApiV10RoleId();
const deleteRoleMutation = useDeleteApiV10RoleId();
const roles = (((rolesData as unknown as { responseData?: { rows?: Role[] } })?.responseData?.rows) || []) as Role[];
const totalRoles = ((rolesData as unknown as { responseData?: { count?: number } })?.responseData?.count) || 0;
// Handlers
const handleCreateRole = () => {
setSelectedRole(null);
setEditForm({ name: "", description: "", permissions: [] });
setIsEditDialogOpen(true);
};
const handleEditRole = (role: Role) => {
setSelectedRole(role);
setEditForm({
name: role.name,
description: role.description || "",
permissions: role.permissions || [],
});
setIsEditDialogOpen(true);
};
const handleDeleteRole = (role: Role) => {
setRoleToDelete(role);
setIsDeleteDialogOpen(true);
};
const handleTogglePermission = (permission: string) => {
setEditForm((prev) => ({
...prev,
permissions: prev.permissions.includes(permission)
? prev.permissions.filter((p) => p !== permission)
: [...prev.permissions, permission],
}));
};
const handleSaveRole = async () => {
if (!editForm.name.trim()) {
toast.error("Vui lòng nhập tên vai trò");
return;
}
if (editForm.permissions.length === 0) {
toast.error("Vui lòng chọn ít nhất một quyền");
return;
}
try {
if (selectedRole) {
// Update existing role (name, description, permissions)
await updateRoleMutation.mutateAsync({
id: selectedRole.id,
data: {
name: editForm.name,
description: editForm.description || null,
permissions: editForm.permissions,
},
});
toast.success("Cập nhật vai trò thành công!");
} else {
// Create new role with permissions
await createRoleMutation.mutateAsync({
data: {
name: editForm.name,
description: editForm.description || undefined,
permissions: editForm.permissions,
},
});
toast.success("Tạo vai trò thành công!");
}
setIsEditDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/role"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Lưu vai trò thất bại");
}
};
const handleConfirmDelete = async () => {
if (!roleToDelete) return;
try {
await deleteRoleMutation.mutateAsync({ id: roleToDelete.id });
toast.success("Xóa vai trò thành công!");
setIsDeleteDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/role"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Xóa vai trò thất bại");
}
};
if (!canReadRoles) {
return <NoPermissionMessage />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[#163b73]">Quản lý Vai trò</h1>
<p className="mt-1 text-sm text-slate-600">
Quản lý vai trò và phân quyền cho người dùng ({totalRoles} vai trò)
</p>
</div>
<PermissionGate required="roles:write">
<Button
onClick={handleCreateRole}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Tạo vai trò mới
</Button>
</PermissionGate>
</div>
{/* Loading State */}
{isLoading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
</div>
)}
{/* Roles List */}
{!isLoading && (
<div className="grid gap-4">
{roles.map((role) => (
<Card
key={role.id}
className="rounded-[20px] border-[#063e8e]/10 transition-all hover:border-[#063e8e]/30 hover:shadow-md"
>
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#063e8e]/10">
<Shield className="h-6 w-6 text-[#063e8e]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#163b73]">{role.name}</h3>
{SYSTEM_ROLES.includes(role.name) && (
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-700"
>
Hệ thống
</Badge>
)}
</div>
<p className="mt-1 text-sm text-slate-600">
{role.description || "Không có mô tả"}
</p>
<div className="mt-2 flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{role.user_count || 0} người dùng
</span>
<span>{role.permissions?.length || 0} quyền</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<PermissionGate required="roles:write">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditRole(role)}
className="h-9 rounded-xl text-[#063e8e] hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
>
<Edit className="mr-1 h-4 w-4" />
Sửa
</Button>
</PermissionGate>
<PermissionGate required="roles:delete">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteRole(role)}
disabled={SYSTEM_ROLES.includes(role.name)}
className="h-9 rounded-xl text-red-600 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
>
<Trash2 className="mr-1 h-4 w-4" />
Xóa
</Button>
</PermissionGate>
</div>
</div>
{/* Permissions Preview */}
<div className="mt-4 flex flex-wrap gap-2">
{(role.permissions || []).slice(0, 8).map((perm) => (
<Badge
key={perm}
variant="outline"
className="border-[#063e8e]/20 bg-[#f8fbff] text-xs"
>
{perm}
</Badge>
))}
{(role.permissions?.length || 0) > 8 && (
<Badge
variant="outline"
className="border-[#063e8e]/20 bg-[#f8fbff] text-xs"
>
+{role.permissions.length - 8} khác
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{roles.length === 0 && !isLoading && (
<div className="rounded-[20px] border border-[#063e8e]/10 bg-[#f8fbff] p-10 text-center">
<p className="text-slate-500">Chưa có vai trò nào</p>
</div>
)}
{/* Pagination */}
{totalRoles > 10 && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Hiển thị {(currentPage - 1) * 10 + 1} - {Math.min(currentPage * 10, totalRoles)} trong {totalRoles} vai trò
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || isLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-3 text-sm font-medium text-[#163b73]">
Trang {currentPage} / {Math.ceil(totalRoles / 10)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={currentPage >= Math.ceil(totalRoles / 10) || isLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Edit/Create Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-hidden rounded-3xl border-[#063e8e]/15">
<DialogHeader>
<DialogTitle className="text-xl text-[#163b73]">
{selectedRole ? "Sửa vai trò" : "Tạo vai trò mới"}
</DialogTitle>
<DialogDescription>
{selectedRole
? "Cập nhật thông tin và quyền hạn của vai trò"
: "Tạo vai trò mới và phân quyền hạn"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto px-1 py-2 max-h-[60vh]">
<div className="space-y-2">
<Label className="text-gray-700">Tên vai trò *</Label>
<Input
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="Nhập tên vai trò..."
className="rounded-xl border-[#063e8e]/15"
disabled={!!selectedRole && SYSTEM_ROLES.includes(selectedRole.name)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-700">Mô tả</Label>
<Textarea
value={editForm.description}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, description: e.target.value }))
}
placeholder="Nhập mô tả vai trò..."
className="rounded-xl border-[#063e8e]/15"
rows={2}
/>
</div>
<div className="space-y-3">
<Label className="text-gray-700">Quyền hạn *</Label>
<div className="max-h-[300px] space-y-4 overflow-y-auto rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] p-4">
{ALL_PERMISSIONS.map((group) => (
<div key={group.resource} className="space-y-2">
<div className="flex items-center gap-2 font-medium text-[#163b73]">
<span>{group.label}</span>
<span className="text-xs text-slate-500">({group.resource})</span>
</div>
<div className="flex flex-wrap gap-3 pl-2">
{group.actions.map((action) => {
const permString = `${group.resource}:${action}`;
const isChecked = editForm.permissions.includes(permString);
return (
<label
key={action}
className="flex cursor-pointer items-center gap-2"
>
<Checkbox
checked={isChecked}
onCheckedChange={() => handleTogglePermission(permString)}
className="border-[#063e8e]/30 data-[state=checked]:bg-[#063e8e] data-[state=checked]:border-[#063e8e]"
/>
<span className="text-sm text-slate-700">
{action}
</span>
</label>
);
})}
</div>
</div>
))}
</div>
<p className="text-xs text-slate-500">
Đã chọn: {editForm.permissions.length} quyền
</p>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
className="rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleSaveRole}
disabled={
!editForm.name.trim() ||
editForm.permissions.length === 0 ||
createRoleMutation.isPending
}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{createRoleMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Check className="mr-1 h-4 w-4" />
Lưu thay đổi
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="rounded-3xl border-red-200">
<DialogHeader>
<DialogTitle className="text-xl text-red-600">Xác nhận xóa vai trò</DialogTitle>
<DialogDescription>
Bạn có chắc muốn xóa vai trò "{roleToDelete?.name}"? Hành động này không thể
hoàn tác.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
className="rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deleteRoleMutation.isPending}
className="rounded-xl bg-red-600 hover:bg-red-700"
>
{deleteRoleMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Xóa vai trò
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
"use client";
import React, { useState, useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
Plus,
Trash2,
UserCog,
Shield,
Search,
Mail,
CheckCircle,
XCircle,
Key,
RefreshCw,
Loader2,
ChevronLeft,
ChevronRight,
Filter,
X,
Eye,
EyeOff,
Calendar,
Clock,
MoreHorizontal,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NoPermissionMessage, PermissionGate } from "@/components/shared/permission-gate";
import { usePermission } from "@/hooks/usePermission";
// API imports
import {
useGetApiV10User,
usePostApiV10User,
useDeleteApiV10UserId,
usePatchApiV10UserIdStatus,
usePostApiV10UserIdResetPassword,
usePostApiV10UserIdRole,
useGetApiV10UserId,
usePutApiV10UserId,
putApiV10UserId,
} from "@/api/endpoints/user";
import type { UserUpdate } from "@/api/models/userUpdate";
import { useGetApiV10Role } from "@/api/endpoints/role";
// Types
interface User {
id: string;
email: string;
username?: string;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
status: string;
type?: string;
gender?: string | null;
hometown?: string | null;
bio?: string | null;
roles?: string[];
user_auth?: {
must_change_password?: boolean;
};
last_login_at?: string | null;
created_at?: string;
updated_at?: string;
}
interface Role {
id: string;
name: string;
description?: string;
user_count?: number;
}
interface UserFilters {
search: string;
status: string;
role: string;
}
const PAGE_SIZE = 10;
function formatDate(dateStr?: string | null) {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatRelativeTime(dateStr?: string | null) {
if (!dateStr) return "Chưa đăng nhập";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Hôm nay";
if (diffDays === 1) return "Hôm qua";
if (diffDays < 7) return `${diffDays} ngày trước`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} tuần trước`;
return `${Math.floor(diffDays / 30)} tháng trước`;
}
export default function UsersPage() {
const canReadUsers = usePermission("users", "read");
const canWriteUsers = usePermission("users", "write");
const canDeleteUsers = usePermission("users", "delete");
const queryClient = useQueryClient();
// Filters
const [filters, setFilters] = useState<UserFilters>({
search: "",
status: "",
role: "",
});
const [currentPage, setCurrentPage] = useState(1);
// Dialogs
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [isRoleDialogOpen, setIsRoleDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [userRoles, setUserRoles] = useState<string[]>([]);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
// Create form
const [createForm, setCreateForm] = useState({
email: "",
password: "vcci@2026",
username: "",
first_name: "",
last_name: "",
});
// Edit form
const [editForm, setEditForm] = useState({
username: "",
first_name: "",
last_name: "",
});
const [showPassword, setShowPassword] = useState(false);
const [newPassword, setNewPassword] = useState("");
// Build API params
const apiParams = useMemo(() => {
const params: Record<string, string | number> = {
page: currentPage,
pageSize: PAGE_SIZE,
sortField: "created_at",
sortOrder: "desc",
};
const filterConditions: string[] = [];
if (filters.search) {
filterConditions.push(`email:$like:${filters.search}`);
}
if (filters.status) {
filterConditions.push(`status==${filters.status}`);
}
if (filters.role) {
filterConditions.push(`role==${filters.role}`);
}
if (filterConditions.length > 0) {
params.filters = filterConditions.join(",");
}
return params;
}, [currentPage, filters]);
// Fetch users with filters
const { data: usersData, isLoading: usersLoading, isFetching } = useGetApiV10User(
apiParams as Parameters<typeof useGetApiV10User>[0]
);
// Fetch single user for edit
const { data: editUserData } = useGetApiV10UserId(
selectedUser?.id || "",
selectedUser ? {} : { query: { enabled: false } } as Parameters<typeof useGetApiV10UserId>[1]
);
// Fetch roles
const { data: rolesData } = useGetApiV10Role({
page: 1,
pageSize: 100,
});
// Mutations
const createUserMutation = usePostApiV10User();
const updateUserMutation = usePutApiV10UserId();
const deleteUserMutation = useDeleteApiV10UserId();
const toggleStatusMutation = usePatchApiV10UserIdStatus();
const resetPasswordMutation = usePostApiV10UserIdResetPassword();
const assignRoleMutation = usePostApiV10UserIdRole();
// Data
const roles: Role[] = ((rolesData as unknown as { responseData?: { rows?: Role[] } })?.responseData?.rows) || [];
const users: User[] = ((usersData as unknown as { responseData?: { rows?: User[] } })?.responseData?.rows) || [];
const totalUsers = ((usersData as unknown as { responseData?: { count?: number } })?.responseData?.count) || 0;
const totalPages = Math.ceil(totalUsers / PAGE_SIZE);
// Handlers
const handleOpenEdit = (user: User) => {
setSelectedUser(user);
setEditForm({
username: user.username || "",
first_name: user.first_name || "",
last_name: user.last_name || "",
});
setNewPassword("");
setIsEditDialogOpen(true);
};
const handleCreateUser = async () => {
if (!createForm.email.trim()) {
toast.error("Vui lòng nhập email");
return;
}
try {
await createUserMutation.mutateAsync({
data: {
email: createForm.email,
password: createForm.password,
username: createForm.username || "",
first_name: createForm.first_name || undefined,
last_name: createForm.last_name || undefined,
},
});
toast.success("Tạo người dùng thành công!");
setIsCreateDialogOpen(false);
setCreateForm({
email: "",
password: "vcci@2026",
username: "",
first_name: "",
last_name: "",
});
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
setCurrentPage(1);
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Tạo người dùng thất bại");
}
};
const handleUpdateUser = async () => {
if (!selectedUser) return;
try {
const updateData: Record<string, string | undefined> = {
username: editForm.username || undefined,
first_name: editForm.first_name || undefined,
last_name: editForm.last_name || undefined,
};
if (newPassword) {
updateData.password = newPassword;
}
await updateUserMutation.mutateAsync({
id: selectedUser.id,
data: updateData as UserUpdate,
});
toast.success("Cập nhật người dùng thành công!");
setIsEditDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Cập nhật người dùng thất bại");
}
};
const handleDeleteUser = async () => {
if (!userToDelete) return;
try {
await deleteUserMutation.mutateAsync({ id: userToDelete.id });
toast.success("Xóa người dùng thành công!");
setIsDeleteDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Xóa người dùng thất bại");
}
};
const handleToggleStatus = async (user: User) => {
const newStatus = user.status === "active" ? "inactive" : "active";
try {
await toggleStatusMutation.mutateAsync({
id: user.id,
data: { status: newStatus },
});
toast.success(`Đã ${newStatus === "active" ? "kích hoạt" : "vô hiệu hóa"} tài khoản`);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Thay đổi trạng thái thất bại");
}
};
const handleResetPassword = async (user: User) => {
try {
await resetPasswordMutation.mutateAsync({ id: user.id });
toast.success(`Đã reset mật khẩu về vcci@2026 cho ${user.email}`);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Reset mật khẩu thất bại");
}
};
const handleOpenRoleDialog = (user: User) => {
setSelectedUser(user);
setUserRoles(user.roles || []);
setIsRoleDialogOpen(true);
};
const handleToggleRole = (roleName: string) => {
setUserRoles((prev) =>
prev.includes(roleName)
? prev.filter((r) => r !== roleName)
: [...prev, roleName]
);
};
const handleSaveRoles = async () => {
if (!selectedUser || userRoles.length === 0) {
toast.error("Vui lòng chọn ít nhất một vai trò");
return;
}
try {
const primaryRole = roles.find((r) => r.name === userRoles[0]);
if (primaryRole) {
await assignRoleMutation.mutateAsync({
id: selectedUser.id,
data: {
role_id: primaryRole.id,
is_primary: true,
},
});
}
toast.success("Cập nhật vai trò thành công!");
setIsRoleDialogOpen(false);
queryClient.invalidateQueries({ queryKey: ["/api/v1.0/user"], exact: false });
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(err?.message || "Cập nhật vai trò thất bại");
}
};
const handleSearchChange = (value: string) => {
setFilters((prev) => ({ ...prev, search: value }));
setCurrentPage(1);
};
const handleStatusFilterChange = (value: string) => {
setFilters((prev) => ({ ...prev, status: value }));
setCurrentPage(1);
};
const handleRoleFilterChange = (value: string) => {
setFilters((prev) => ({ ...prev, role: value }));
setCurrentPage(1);
};
const clearFilters = () => {
setFilters({ search: "", status: "", role: "" });
setCurrentPage(1);
};
const hasActiveFilters = filters.search || filters.status || filters.role;
if (!canReadUsers) {
return <NoPermissionMessage />;
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-[#163b73]">Quản lý Người dùng</h1>
<p className="mt-1 text-sm text-slate-600">
Quản lý tài khoản và vai trò người dùng
</p>
</div>
<PermissionGate required="users:write">
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
<Plus className="mr-2 h-4 w-4" />
Thêm người dùng
</Button>
</PermissionGate>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-4 rounded-2xl border border-[#063e8e]/10 bg-white p-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Tìm kiếm email..."
value={filters.search}
onChange={(e) => handleSearchChange(e.target.value)}
className="rounded-xl border-[#063e8e]/15 pl-10"
/>
</div>
<Select value={filters.status} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-44 rounded-xl border-[#063e8e]/15">
<SelectValue placeholder="Trạng thái" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả</SelectItem>
<SelectItem value="active">Hoạt động</SelectItem>
<SelectItem value="pending_verification">Chờ duyệt</SelectItem>
<SelectItem value="inactive">Không hoạt động</SelectItem>
</SelectContent>
</Select>
<Select value={filters.role} onValueChange={handleRoleFilterChange}>
<SelectTrigger className="w-[180px] rounded-xl border-[#063e8e]/15">
<SelectValue placeholder="Vai trò" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả vai trò</SelectItem>
{roles.map((role) => (
<SelectItem key={role.id} value={role.name}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-10 rounded-xl text-slate-600 hover:text-slate-700"
>
<X className="mr-1 h-4 w-4" />
Xóa lọc
</Button>
)}
<div className="ml-auto text-sm text-slate-500">
{isFetching && !usersLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Đang tải...
</span>
) : (
`${totalUsers} người dùng`
)}
</div>
</div>
{/* Users Table */}
<div className="rounded-2xl border border-[#063e8e]/10 bg-white overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-0 bg-[#063e8e] hover:bg-[#063e8e]">
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">STT</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Email</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Họ tên</TableHead>
<TableHead className="py-3 text-white text-sm font-semibold whitespace-nowrap">Vai trò</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">Trạng thái</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">Ngày tạo</TableHead>
<TableHead className="py-3 text-center text-white text-sm font-semibold whitespace-nowrap">Cập nhật</TableHead>
<TableHead className="w-24 py-3 text-center text-white text-sm font-semibold whitespace-nowrap">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersLoading ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-[#063e8e]" />
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-slate-500">
Không tìm thấy người dùng nào
</TableCell>
</TableRow>
) : (
users.map((user, index) => (
<TableRow
key={user.id}
className="border-b border-[#063e8e]/5 hover:bg-[#063e8e]/2"
>
<TableCell className="text-center text-slate-600 whitespace-nowrap">
{(currentPage - 1) * PAGE_SIZE + index + 1}
</TableCell>
<TableCell>
<p className="text-sm text-slate-700 truncate max-w-[180px]" title={user.email}>
{user.email}
</p>
{user.username && (
<p className="text-xs text-slate-400">@{user.username}</p>
)}
</TableCell>
<TableCell>
<p className="text-sm font-medium text-[#163b73] whitespace-nowrap">
{user.first_name || user.last_name ? `${user.first_name || ""} ${user.last_name || ""}`.trim() : "-"}
</p>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1 max-w-[120px]">
{user.roles?.slice(0, 2).map((roleName) => (
<Badge
key={roleName}
variant="secondary"
className="border border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5 whitespace-nowrap"
>
{roleName}
</Badge>
))}
{(user.roles?.length || 0) > 2 && (
<Badge
variant="secondary"
className="border border-[#063e8e]/20 bg-[#f8fbff] text-sm px-2 py-0.5"
>
+{user.roles!.length - 2}
</Badge>
)}
{(!user.roles || user.roles.length === 0) && (
<span className="text-sm text-slate-400">-</span>
)}
</div>
</TableCell>
<TableCell className="text-center">
<div className="flex flex-col items-center gap-1">
<Badge
variant="outline"
className={`text-sm whitespace-nowrap ${
user.status === "active"
? "border-green-200 bg-green-50 text-green-700"
: user.status === "pending_verification"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-red-200 bg-red-50 text-red-700"
}`}
>
{user.status === "active" ? (
<CheckCircle className="mr-1 h-4 w-4" />
) : user.status === "pending_verification" ? (
<Clock className="mr-1 h-4 w-4" />
) : (
<XCircle className="mr-1 h-4 w-4" />
)}
{user.status === "active" ? "Hoạt động" :
user.status === "pending_verification" ? "Chờ duyệt" :
user.status === "inactive" ? "Khóa" : user.status}
</Badge>
{user.user_auth?.must_change_password && (
<Badge
variant="outline"
className="border-slate-200 bg-slate-50 text-slate-600 text-sm px-1.5"
>
<Key className="mr-0.5 h-3 w-3" />
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-center">
<p className="text-sm text-slate-500 whitespace-nowrap">
{user.created_at ? formatDate(user.created_at) : "-"}
</p>
</TableCell>
<TableCell className="text-center">
<p className="text-sm text-slate-500 whitespace-nowrap">
{user.updated_at ? formatDate(user.updated_at) : "-"}
</p>
</TableCell>
<TableCell>
<PermissionGate required="users:write">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 rounded-lg"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleOpenEdit(user)}>
<UserCog className="mr-2 h-4 w-4" />
Chỉnh sửa
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleOpenRoleDialog(user)}>
<Shield className="mr-2 h-4 w-4" />
Gán vai trò
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleResetPassword(user)}>
<RefreshCw className="mr-2 h-4 w-4" />
Reset mật khẩu
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleToggleStatus(user)}
className={user.status === "active" ? "text-red-600" : "text-green-600"}
>
{user.status === "active" ? (
<>
<XCircle className="mr-2 h-4 w-4" />
Vô hiệu hóa
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Kích hoạt
</>
)}
</DropdownMenuItem>
<PermissionGate required="users:delete">
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setUserToDelete(user);
setIsDeleteDialogOpen(true);
}}
className="text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Xóa
</DropdownMenuItem>
</PermissionGate>
</DropdownMenuContent>
</DropdownMenu>
</PermissionGate>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
Hiển thị {(currentPage - 1) * PAGE_SIZE + 1} - {Math.min(currentPage * PAGE_SIZE, totalUsers)} trong {totalUsers} người dùng
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1 || usersLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum: number;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`h-8 w-8 p-0 ${
currentPage === pageNum
? "bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
: "rounded-xl border-[#063e8e]/15"
}`}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => prev + 1)}
disabled={currentPage >= totalPages || usersLoading}
className="rounded-xl border-[#063e8e]/15"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Create User Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-hidden rounded-2xl border-[#063e8e]/15 p-6">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl font-bold text-[#163b73]">Thêm người dùng mới</DialogTitle>
<DialogDescription className="text-sm">
Tạo tài khoản mới. Mật khẩu mặc định: <strong>vcci@2026</strong>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[60vh]">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Email *</Label>
<Input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Họ</Label>
<Input
value={createForm.first_name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, first_name: e.target.value }))}
placeholder="Họ"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Tên</Label>
<Input
value={createForm.last_name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, last_name: e.target.value }))}
placeholder="Tên"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Username</Label>
<Input
value={createForm.username}
onChange={(e) => setCreateForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="username"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu</Label>
<Input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="vcci@2026"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
<p className="text-xs text-slate-500">
Mật khẩu mặc định: vcci@2026
</p>
</div>
</div>
<DialogFooter className="gap-2 mt-4">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
className="h-10 rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleCreateUser}
disabled={!createForm.email || createUserMutation.isPending}
className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{createUserMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Tạo người dùng
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-hidden rounded-2xl border-[#063e8e]/15 p-6">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl font-bold text-[#163b73]">Chỉnh sửa người dùng</DialogTitle>
<DialogDescription className="text-sm">
Cập nhật thông tin người dùng
</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[60vh]">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Email</Label>
<Input
type="email"
value={selectedUser?.email || ""}
disabled
className="h-10 rounded-xl border-[#063e8e]/15 bg-slate-50 text-slate-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-sm font-medium">Họ</Label>
<Input
value={editForm.first_name}
onChange={(e) => setEditForm((prev) => ({ ...prev, first_name: e.target.value }))}
placeholder="Họ"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Tên</Label>
<Input
value={editForm.last_name}
onChange={(e) => setEditForm((prev) => ({ ...prev, last_name: e.target.value }))}
placeholder="Tên"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Username</Label>
<Input
value={editForm.username}
onChange={(e) => setEditForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="username"
className="h-10 rounded-xl border-[#063e8e]/15"
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">Mật khẩu mới</Label>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Để trống nếu không đổi mật khẩu"
className="h-10 rounded-xl border-[#063e8e]/15 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="text-xs text-slate-500">
Để trống nếu không muốn thay đổi mật khẩu
</p>
</div>
</div>
<DialogFooter className="gap-2 mt-4">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
className="h-10 rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleUpdateUser}
disabled={updateUserMutation.isPending}
className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{updateUserMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Lưu thay đổi
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Assign Role Dialog */}
<Dialog open={isRoleDialogOpen} onOpenChange={setIsRoleDialogOpen}>
<DialogContent className="max-w-lg rounded-2xl border-[#063e8e]/15 p-6">
<DialogHeader className="mb-4">
<DialogTitle className="text-xl font-bold text-[#163b73]">Gán vai trò</DialogTitle>
<DialogDescription className="text-sm">
Gán vai trò cho người dùng: <strong>{selectedUser?.email}</strong>
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
{roles.map((role) => {
const isSelected = userRoles.includes(role.name);
return (
<label
key={role.id}
className={`flex cursor-pointer items-center gap-4 rounded-2xl border p-4 transition-all ${
isSelected
? "border-[#063e8e] bg-[#f8fbff]"
: "border-[#063e8e]/10 hover:border-[#063e8e]/30"
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleRole(role.name)}
className="border-[#063e8e]/30 data-[state=checked]:bg-[#063e8e] data-[state=checked]:border-[#063e8e]"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-[#063e8e]" />
<span className="font-semibold text-[#163b73]">{role.name}</span>
{role.name === "system_admin" && (
<Badge variant="outline" className="border-slate-200 bg-slate-50 text-slate-700 text-xs">
Hệ thống
</Badge>
)}
</div>
<p className="mt-1 text-xs text-slate-500">
{role.description || "Không có mô tả"}
</p>
</div>
</label>
);
})}
</div>
<DialogFooter className="gap-2 mt-4">
<Button
variant="outline"
onClick={() => setIsRoleDialogOpen(false)}
className="h-10 rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
onClick={handleSaveRoles}
disabled={userRoles.length === 0 || assignRoleMutation.isPending}
className="h-10 rounded-xl bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
>
{assignRoleMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Lưu vai trò
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="rounded-3xl border-red-200">
<DialogHeader>
<DialogTitle className="text-xl text-red-600">Xác nhận xóa người dùng</DialogTitle>
<DialogDescription>
Bạn có chắc muốn xóa người dùng "{userToDelete?.email}"? Hành động này không thể hoàn tác.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
className="rounded-xl border-[#063e8e]/15"
>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleDeleteUser}
disabled={deleteUserMutation.isPending}
className="rounded-xl bg-red-600 hover:bg-red-700"
>
{deleteUserMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Xóa người dùng
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
"use client";
import * as React from "react";
import { Image as ImageIcon, Plus, Type, Upload, X } from "lucide-react";
import { Image as ImageIcon, Pencil, Plus, Type, Upload, X } from "lucide-react";
import { AdminImagePicker } from "@/components/admin/image-picker";
import { AdminRichTextEditor } from "@/components/admin/rich-text-editor";
import { SafeNextImage } from "@/components/admin/safe-next-image";
......@@ -25,6 +25,86 @@ interface AdminPostContentEditorProps {
onChange: (sections: AdminNewsContentSection[]) => void;
}
function CaptionEditor({
caption,
onCaptionChange,
}: {
caption: string;
onCaptionChange: (caption: string) => void;
}) {
const [isEditing, setIsEditing] = React.useState(false);
const [value, setValue] = React.useState(caption);
React.useEffect(() => {
setValue(caption);
}, [caption]);
const handleSave = () => {
onCaptionChange(value.trim());
setIsEditing(false);
};
const handleCancel = () => {
setValue(caption);
setIsEditing(false);
};
if (isEditing) {
return (
<div className="flex flex-col gap-2 p-2">
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
}}
placeholder="Nhập chú thích cho ảnh..."
className="w-full rounded-lg border border-[#063e8e]/15 bg-white px-3 py-2 text-sm text-gray-700 placeholder:text-gray-500 focus:border-[#063e8e]/30 focus:outline-none focus:ring-2 focus:ring-[#063e8e]/20"
autoFocus
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
className="flex-1 rounded-lg bg-[#063e8e] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#063e8e]/90"
>
Lưu
</button>
<button
type="button"
onClick={handleCancel}
className="flex-1 rounded-lg border border-[#063e8e]/15 px-3 py-1.5 text-xs font-medium text-gray-700 transition hover:bg-[#063e8e]/5"
>
Hủy
</button>
</div>
</div>
);
}
return (
<div className="group/piece flex min-h-11 items-center justify-between gap-2 border-t border-[#063e8e]/10 px-3 py-2">
{caption ? (
<p className="flex-1 text-center text-xs italic text-gray-700">{caption}</p>
) : (
<p className="flex-1 text-center text-xs italic text-gray-500">
Thêm chú thích...
</p>
)}
<button
type="button"
onClick={() => setIsEditing(true)}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg text-gray-400 transition hover:bg-[#063e8e]/10 hover:text-[#063e8e]"
title="Sửa chú thích"
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
);
}
export function AdminPostContentEditor({
sections,
onChange,
......@@ -98,6 +178,7 @@ export function AdminPostContentEditor({
const nextImages = section.images.filter((image) => image.position !== pickerState.position);
nextImages.push({
position: pickerState.position,
caption: "",
image: {
id: item.id,
name: item.name,
......@@ -295,10 +376,16 @@ export function AdminPostContentEditor({
const currentImage = section.images.find(
(image) => image.position === position,
);
const currentImageId = currentImage?.image?.id ?? null;
return (
<div
key={`${section.id}-${position}`}
className="group flex flex-col overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white transition hover:border-[#063e8e]/40"
>
{currentImage ? (
<>
<div
role="button"
tabIndex={0}
onClick={() =>
......@@ -306,7 +393,7 @@ export function AdminPostContentEditor({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
selectedId: currentImageId,
})
}
onKeyDown={(event) => {
......@@ -316,14 +403,12 @@ export function AdminPostContentEditor({
open: true,
sectionId: section.id,
position,
selectedId: currentImage?.image.id ?? null,
selectedId: currentImageId,
});
}
}}
className="group relative flex aspect-square cursor-pointer items-center justify-center overflow-hidden rounded-2xl border border-dashed border-[#063e8e]/20 bg-white text-center transition hover:border-[#063e8e]/40"
className="relative h-64 w-full cursor-pointer"
>
{currentImage ? (
<>
<SafeNextImage
src={currentImage.image.url}
alt={currentImage.image.alt || currentImage.image.name}
......@@ -340,14 +425,53 @@ export function AdminPostContentEditor({
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div onClick={(e) => e.stopPropagation()}>
<CaptionEditor
caption={currentImage.caption}
onCaptionChange={(caption) => {
updateSection(section.id, (sec) => ({
...sec,
images: sec.images.map((img) =>
img.position === position ? { ...img, caption } : img
),
}));
}}
/>
</div>
</>
) : (
<div className="px-3 text-center">
<div
role="button"
tabIndex={0}
onClick={() =>
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImageId,
})
}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setPickerState({
open: true,
sectionId: section.id,
position,
selectedId: currentImageId,
});
}
}}
className="flex h-40 w-full shrink-0 cursor-pointer items-center justify-center px-3 text-center"
>
<div>
<Upload className="mx-auto mb-2 h-5 w-5 text-[#063e8e]" />
<p className="text-xs font-medium text-gray-700">
Chọn ảnh {position}
</p>
</div>
</div>
)}
</div>
);
......
"use client";
import dynamic from "next/dynamic";
import * as React from "react";
import { useMemo, useRef } from "react";
import type { JoditEditorProps } from "jodit-react";
import useAuthStore from "@/store/useAuthStore";
......@@ -57,13 +58,130 @@ const IMPORTED_LAYOUT_SELECTORS = [
"iframe",
];
function cleanPastedHtml(value: string) {
if (typeof window === "undefined" || !value.trim()) return value;
const parser = new DOMParser();
const doc = parser.parseFromString(value, "text/html");
// Strip ALL attributes except allowed ones
const allowedAttrs = new Set(["href", "src", "alt", "target", "rel"]);
doc.querySelectorAll("*").forEach((el) => {
const attrs = Array.from(el.attributes);
attrs.forEach((attr) => {
if (!allowedAttrs.has(attr.name)) {
el.removeAttribute(attr.name);
}
});
});
// Normalize tags to standard semantic HTML
const tagReplacements: [string[], string | null][] = [
[["B"], "strong"],
[["I"], "em"],
[["U"], "u"],
[["S", "STRIKE", "DEL"], "s"],
[["SPAN"], null], // unwrap span
[["FONT"], null], // unwrap font
[["CENTER"], null], // unwrap center
[["BIG"], null],
[["SMALL"], null],
[["H1", "H2", "H3", "H4", "H5", "H6"], null], // unwrap headings to p
];
tagReplacements.forEach(([tags, newTag]) => {
doc.querySelectorAll(tags.join(",")).forEach((el) => {
if (newTag === null) {
while (el.firstChild) {
el.parentNode?.insertBefore(el.firstChild, el);
}
el.remove();
} else {
const newEl = doc.createElement(newTag);
while (el.firstChild) newEl.appendChild(el.firstChild);
el.replaceWith(newEl);
}
});
});
// Handle images
doc.querySelectorAll("img").forEach((el) => {
el.setAttribute("style", "display:block;width:100%;max-width:100%;height:auto;");
el.removeAttribute("width");
el.removeAttribute("height");
el.removeAttribute("style");
el.setAttribute("style", "display:block;width:100%;max-width:100%;height:auto;");
});
// Handle tables
doc.querySelectorAll("table").forEach((el) => {
el.setAttribute("style", "width:100%;max-width:100%;border-collapse:collapse;");
el.querySelectorAll("td, th").forEach((cell) => {
cell.setAttribute("style", "border:1px solid #ddd;padding:8px;");
});
});
// Handle figure tags
doc.querySelectorAll("figure").forEach((el) => {
el.setAttribute("style", "display:block;margin:1.5rem 0;");
});
// Unwrap divs that contain block elements
const body = doc.body;
const blockTags = new Set(["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI", "TABLE", "BLOCKQUOTE", "PRE", "IMG", "HR"]);
function unwrapDivs() {
let changed = true;
while (changed) {
changed = false;
Array.from(body.querySelectorAll("div")).forEach((div) => {
const children = Array.from(div.childNodes);
if (children.length > 0) {
const allBlock = children.every((child) =>
child.nodeType === 1 ? blockTags.has((child as Element).tagName) : child.textContent?.trim() === ""
);
if (allBlock || div.style.cssText === "") {
while (div.firstChild) {
div.parentNode?.insertBefore(div.firstChild, div);
}
div.remove();
changed = true;
}
} else {
div.remove();
changed = true;
}
});
}
}
unwrapDivs();
// Clean up empty tags
const emptyTags = ["span", "div", "p", "strong", "em", "u", "s", "b", "i"];
let hasEmpty = true;
while (hasEmpty) {
hasEmpty = false;
emptyTags.forEach((tag) => {
doc.querySelectorAll(`${tag}:empty`).forEach((el) => {
if (!el.querySelector("img")) {
el.remove();
hasEmpty = true;
}
});
});
}
return doc.body.innerHTML;
}
function normalizeImportedHtml(value: string) {
if (typeof window === "undefined" || !value.trim()) return value;
const parser = new DOMParser();
const document = parser.parseFromString(value, "text/html");
const doc = parser.parseFromString(value, "text/html");
document.body.querySelectorAll<HTMLElement>(IMPORTED_LAYOUT_SELECTORS.join(",")).forEach((element) => {
doc.body.querySelectorAll<HTMLElement>(IMPORTED_LAYOUT_SELECTORS.join(",")).forEach((element) => {
element.style.removeProperty("width");
element.style.removeProperty("max-width");
element.style.removeProperty("min-width");
......@@ -94,7 +212,7 @@ function normalizeImportedHtml(value: string) {
}
});
return document.body.innerHTML;
return doc.body.innerHTML;
}
export function AdminRichTextEditor({
......@@ -107,6 +225,54 @@ export function AdminRichTextEditor({
}: AdminRichTextEditorProps) {
const editor = useRef(null);
const accessToken = useAuthStore((state) => state.appAccessToken);
const [isFormatting, setIsFormatting] = React.useState(false);
const [localValue, setLocalValue] = React.useState(value);
const formatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastValueRef = useRef(value);
// Sync local value when prop changes
React.useEffect(() => {
if (!isFormatting) {
setLocalValue(value);
lastValueRef.current = value;
}
}, [value, isFormatting]);
const handleFormat = React.useCallback((html: string) => {
setIsFormatting(true);
// Don't update localValue - keep showing previous content during formatting
// Clean after delay to show "đang format" message
if (formatTimeoutRef.current) {
clearTimeout(formatTimeoutRef.current);
}
formatTimeoutRef.current = setTimeout(() => {
const cleaned = cleanPastedHtml(html);
setLocalValue(cleaned);
onChange(cleaned);
lastValueRef.current = cleaned;
setIsFormatting(false);
}, 3500); // 3.5s delay to show formatting message
}, [onChange]);
const handleEditorChange = React.useCallback((html: string) => {
// Detect if this looks like pasted content (has style attributes)
const hasInlineStyles = html.includes('style="') || html.includes("style='");
const hasFontTag = html.includes("<font");
const hasClassAttr = html.includes('class="') || html.includes("class='");
const contentLengthChanged = Math.abs(html.length - lastValueRef.current.length) > 50;
if ((hasInlineStyles || hasFontTag || hasClassAttr) && contentLengthChanged) {
// This looks like pasted content, run clean
handleFormat(html);
} else {
// Normal typing, update directly
setLocalValue(html);
lastValueRef.current = html;
onChange(html);
}
}, [handleFormat, onChange]);
const config: JoditEditorProps["config"] = useMemo(
() => ({
......@@ -302,16 +468,88 @@ export function AdminRichTextEditor({
.admin-rich-text-editor .jodit-placeholder {
color: #374151 !important;
}
.admin-rich-text-editor .editor-wrapper {
position: relative;
border-radius: 1rem;
}
.admin-rich-text-editor .editor-wrapper.is-formatting::after {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
z-index: 10;
border-radius: 1rem;
}
.admin-rich-text-editor .format-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.98);
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1rem;
}
.admin-rich-text-editor .format-overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.admin-rich-text-editor .format-spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(6, 62, 142, 0.2);
border-top-color: #063e8e;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.admin-rich-text-editor .format-text {
font-size: 14px;
font-weight: 500;
color: #063e8e;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
<div className="admin-rich-text-editor">
<div className="editor-wrapper">
<JoditEditor
ref={editor}
value={value}
value={localValue}
config={config}
onBlur={(nextContent) => onChange(normalizeImportedHtml(nextContent))}
onChange={() => undefined}
onBlur={(nextContent) => {
const cleaned = cleanPastedHtml(nextContent);
setLocalValue(cleaned);
onChange(cleaned);
lastValueRef.current = cleaned;
}}
onChange={handleEditorChange}
/>
{isFormatting && (
<div className="format-overlay">
<div className="format-overlay-content">
<div className="format-spinner" />
<div className="format-text">Đang format nội dung...</div>
</div>
</div>
)}
</div>
</div>
</div>
);
......
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ensureValidAdminAccessToken } from "@/lib/auth/admin-auth";
import { ensureValidAdminAccessToken, logoutAdmin } from "@/lib/auth/admin-auth";
import useAuthStore from "@/store/useAuthStore";
const LOGIN_PATH = "/admin/login";
const CHANGE_PASSWORD_PATH = "/admin/change-password";
const PROACTIVE_REFRESH_INTERVAL_MS = 60 * 1000;
const getRefreshTokenExpiredAt = (session?: { refresh_expires_at?: string | null } | null) => {
if (!session?.refresh_expires_at) return null;
const time = new Date(session.refresh_expires_at).getTime();
return Number.isFinite(time) ? time : null;
};
const isSessionUsable = () => {
const state = useAuthStore.getState();
if (!state.appRefreshToken) return false;
const refreshExpiredAt = getRefreshTokenExpiredAt(state.appSession);
return !refreshExpiredAt || refreshExpiredAt > Date.now();
};
export function AdminAuthLoadingScreen() {
return (
......@@ -75,12 +90,26 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const accessTokenExpired = useAuthStore((state) => state.appAccessTokenExpired);
const refreshToken = useAuthStore((state) => state.appRefreshToken);
const isRefreshing = useAuthStore((state) => state.appIsRefreshing);
const mustChangePassword = useAuthStore((state) => state.appUser?.must_change_password === true);
const [authCheckState, setAuthCheckState] = useState<"idle" | "checking" | "ready">("idle");
const redirectParam =
typeof window === "undefined"
? encodeURIComponent(pathname || "/admin")
: encodeURIComponent(`${window.location.pathname}${window.location.search}`);
// Force redirect to change-password page if user must change password
useEffect(() => {
if (
authCheckState === "ready" &&
isLoggedIn &&
mustChangePassword &&
pathname !== CHANGE_PASSWORD_PATH &&
pathname !== LOGIN_PATH
) {
router.replace(CHANGE_PASSWORD_PATH);
}
}, [authCheckState, isLoggedIn, mustChangePassword, pathname, router]);
useEffect(() => {
if (pathname === LOGIN_PATH) {
setAuthCheckState("ready");
......@@ -97,36 +126,41 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
const restoreSession = async () => {
setAuthCheckState("checking");
const hasValidAccessToken = Boolean(
accessToken &&
isLoggedIn &&
(!accessTokenExpired || accessTokenExpired > Date.now()),
);
if (hasValidAccessToken) {
// Refresh token đã hết hạn → logout ngay, không cố refresh nữa
if (!isSessionUsable()) {
if (!cancelled) {
setAuthCheckState("ready");
void logoutAdmin({ silent: true, reason: "missing_refresh_token" });
}
return;
}
if (!refreshToken) {
// Access token còn hạn → OK
const hasValidAccessToken = Boolean(
accessToken &&
isLoggedIn &&
accessTokenExpired !== null &&
accessTokenExpired > Date.now(),
);
if (hasValidAccessToken) {
if (!cancelled) {
setAuthCheckState("ready");
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
}
return;
}
// Access token hết hạn nhưng còn refresh token → thử refresh
try {
const nextToken = await ensureValidAdminAccessToken();
if (!nextToken && !cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
// refresh đã gọi logoutAdmin bên trong, không cần redirect thêm
setAuthCheckState("ready");
}
} catch {
if (!cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
setAuthCheckState("ready");
}
} finally {
if (!cancelled) {
......@@ -137,8 +171,22 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
void restoreSession();
// Proactive token refresh: kiểm tra định kỳ và refresh trước khi hết hạn
const refreshInterval = setInterval(() => {
if (cancelled) return;
const state = useAuthStore.getState();
if (!state.appAccessToken || !state.appRefreshToken) return;
// Refresh token hết hạn → logout ngay
if (!isSessionUsable()) {
void logoutAdmin({ silent: true, reason: "missing_refresh_token" });
return;
}
void ensureValidAdminAccessToken().catch(() => null);
}, PROACTIVE_REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(refreshInterval);
};
}, [
accessToken,
......@@ -151,7 +199,7 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
router,
]);
if (pathname === LOGIN_PATH) {
if (pathname === LOGIN_PATH || pathname === CHANGE_PASSWORD_PATH) {
return <>{children}</>;
}
......@@ -167,6 +215,11 @@ export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
return null;
}
// Nếu user phải đổi mật khẩu, không render nội dung admin (đã redirect ở useEffect)
if (mustChangePassword) {
return <AdminAuthLoadingScreen />;
}
return <>{children}</>;
}
......@@ -201,23 +254,25 @@ export function useAdminAuthStatus() {
const restoreSession = async () => {
setAuthCheckState("checking");
const hasValidAccessToken = Boolean(
accessToken &&
isLoggedIn &&
(!accessTokenExpired || accessTokenExpired > Date.now()),
);
if (hasValidAccessToken) {
// Refresh token đã hết hạn → logout ngay
if (!isSessionUsable()) {
if (!cancelled) {
setAuthCheckState("ready");
void logoutAdmin({ silent: true, reason: "missing_refresh_token" });
}
return;
}
if (!refreshToken) {
const hasValidAccessToken = Boolean(
accessToken &&
isLoggedIn &&
accessTokenExpired !== null &&
accessTokenExpired > Date.now(),
);
if (hasValidAccessToken) {
if (!cancelled) {
setAuthCheckState("ready");
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
}
return;
}
......@@ -226,11 +281,11 @@ export function useAdminAuthStatus() {
const nextToken = await ensureValidAdminAccessToken();
if (!nextToken && !cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
setAuthCheckState("ready");
}
} catch {
if (!cancelled) {
router.replace(`${LOGIN_PATH}?redirect=${redirectParam}`);
setAuthCheckState("ready");
}
} finally {
if (!cancelled) {
......@@ -241,8 +296,21 @@ export function useAdminAuthStatus() {
void restoreSession();
// Proactive token refresh
const refreshInterval = setInterval(() => {
if (cancelled) return;
const state = useAuthStore.getState();
if (!state.appAccessToken || !state.appRefreshToken) return;
if (!isSessionUsable()) {
void logoutAdmin({ silent: true, reason: "missing_refresh_token" });
return;
}
void ensureValidAdminAccessToken().catch(() => null);
}, PROACTIVE_REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(refreshInterval);
};
}, [
accessToken,
......
"use client";
import React, { useEffect, useState } from "react";
import { usePermission } from "@/hooks/usePermission";
import useAuthStore from "@/store/useAuthStore";
import { usePathname, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
interface PagePermissionGuardProps {
children: React.ReactNode;
/** Required permission để truy cập page, ví dụ: "posts:read" */
requiredPermission?: string;
/** Nhiều permissions - cần ít nhất một */
requiredAnyPermission?: string[];
/** Nếu không có quyền, redirect đến trang này */
redirectTo?: string;
/** Component hiển thị khi không có quyền (thay vì redirect) */
fallbackComponent?: React.ReactNode;
}
/**
* Component để bảo vệ page dựa trên permissions
* - Kiểm tra user có permission cần thiết không
* - Nếu không có quyền, redirect hoặc hiển thị fallback
* - Nếu chưa hydrate xong auth store, hiển thị loading
*/
export function PagePermissionGuard({
children,
requiredPermission,
requiredAnyPermission,
redirectTo = "/admin/no-permission",
fallbackComponent,
}: PagePermissionGuardProps) {
const router = useRouter();
const pathname = usePathname();
const [isChecking, setIsChecking] = useState(true);
const hasHydrated = useAuthStore((state) => state._hasHydrated);
const appIsLoggedIn = useAuthStore((state) => state.appIsLoggedIn);
// Kiểm tra permission
const hasPermission = usePermission(
requiredPermission?.split(":")[0] || "",
requiredPermission?.split(":")[1] || ""
);
// Kiểm tra nếu cần ít nhất một permission
const hasAnyPermission = usePermission(
requiredAnyPermission || [],
"any"
);
useEffect(() => {
// Chờ hydrate xong
if (!hasHydrated) {
return;
}
setIsChecking(false);
// Nếu chưa đăng nhập, redirect đến login
if (!appIsLoggedIn) {
router.push("/admin/login");
return;
}
// Kiểm tra permission
const hasAccess = requiredPermission
? hasPermission
: requiredAnyPermission
? hasAnyPermission
: true;
if (!hasAccess) {
if (fallbackComponent) {
// Đã render fallback component ở return bên dưới
} else {
router.push(redirectTo);
}
}
}, [hasHydrated, appIsLoggedIn, hasPermission, hasAnyPermission, requiredPermission, requiredAnyPermission, redirectTo, router, fallbackComponent]);
// Loading state - chờ hydrate
if (!hasHydrated || isChecking) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
<p className="text-sm text-slate-500">Đang kiểm tra quyền truy cập...</p>
</div>
</div>
);
}
// Chưa đăng nhập - sẽ redirect
if (!appIsLoggedIn) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
<p className="text-sm text-slate-500">Đang chuyển hướng...</p>
</div>
</div>
);
}
// Kiểm tra permission cuối cùng
const hasAccess = requiredPermission
? hasPermission
: requiredAnyPermission
? hasAnyPermission
: true;
// Không có quyền và có fallback component
if (!hasAccess && fallbackComponent) {
return <>{fallbackComponent}</>;
}
// Không có quyền - sẽ redirect
if (!hasAccess) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-[#063e8e]" />
<p className="text-sm text-slate-500">Đang kiểm tra quyền truy cập...</p>
</div>
</div>
);
}
return <>{children}</>;
}
/**
* Mapping từ URL path -> required permission
* Sử dụng cho các page admin thông thường
*/
export const ADMIN_PAGE_PERMISSIONS: Record<string, string> = {
"/admin": "", // Dashboard - không cần permission cụ thể
"/admin/base-config": "settings:read",
"/admin/header-config": "categories:read",
"/admin/news": "posts:read",
"/admin/tags": "tags:read",
"/admin/videos": "videos:read",
"/admin/contact-management/newsletter-emails": "newsletter:read",
"/admin/media": "files:read",
"/admin/roles": "roles:read",
"/admin/users": "users:read",
};
/**
* Lấy required permission cho một path
*/
export function getRequiredPermissionForPath(path: string): string | undefined {
// Exact match
if (ADMIN_PAGE_PERMISSIONS[path]) {
return ADMIN_PAGE_PERMISSIONS[path] || undefined;
}
// Prefix match (cho các sub-paths)
for (const [basePath, permission] of Object.entries(ADMIN_PAGE_PERMISSIONS)) {
if (path.startsWith(basePath + "/")) {
return permission || undefined;
}
}
// Default - không cần permission
return undefined;
}
/**
* Component tự động kiểm tra permission dựa trên URL
* Sử dụng trong admin layout
*/
export function AutoPagePermissionGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const requiredPermission = getRequiredPermissionForPath(pathname);
if (!requiredPermission) {
return <>{children}</>;
}
return (
<PagePermissionGuard requiredPermission={requiredPermission}>
{children}
</PagePermissionGuard>
);
}
......@@ -10,17 +10,25 @@ import {
ImagePlus,
Layers,
Mail,
Megaphone,
Newspaper,
Settings,
Sparkles,
Tags,
UserCog,
Shield,
KeyRound,
Video,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useGetApiV10Logo } from "@/api/endpoints/logo";
import type { Logo } from "@/api/models/logo";
const fallbackLogo = "/logo.png";
import logo from "@/assets/VCCI-HCM-logo-VN-2025.png";
import { resolveUploadUrl } from "@/links";
import { useSidebarStore } from "@/hooks/use-admin-sidebar";
import { usePermission } from "@/hooks/usePermission";
import { cn } from "@/lib/utils";
import useAuthStore from "@/store/useAuthStore";
type LogoListEnvelope = {
data?: {
......@@ -29,56 +37,83 @@ type LogoListEnvelope = {
};
};
};
import { useSidebarStore } from "@/hooks/use-admin-sidebar";
import { cn } from "@/lib/utils";
type NavChild = { name: string; href: string };
type NavChild = { name: string; href: string; permission?: string };
type NavItem = {
name: string;
icon: React.ComponentType<{ className?: string }>;
href?: string;
children?: NavChild[];
permission?: string;
};
// Navigation với permissions
const navigation: NavItem[] = [
{ name: "Cấu hình chung", href: "/admin/base-config", icon: Settings },
{ name: "Cấu hình danh mục", href: "/admin/header-config", icon: Layers },
{ name: "Quản lý bài viết", href: "/admin/news", icon: Newspaper },
{ name: "Quản lý tag tìm kiếm", href: "/admin/tags", icon: Tags },
{ name: "Quản lý video", href: "/admin/videos", icon: Video },
// {
// name: 'Quản lý hội viên',
// icon: Users,
// children: [
// { name: 'Danh sách hội viên', href: '/admin/members' },
// { name: 'Quản lý lĩnh vực', href: '/admin/members/fields' },
// { name: 'Quản lý khu vực', href: '/admin/members/regions' },
// ],
// },
// {
// name: 'Quản lý liên hệ',
// icon: Mail,
// children: [
// {
// name: 'Quản lý Email đăng ký nhận thông tin',
// href: '/admin/contact-management/newsletter-emails',
// },
// {
// name: 'Quản lý Đơn liên hệ',
// href: '/admin/contact-management/contact-requests',
// },
// {
// name: 'Quản lý Đơn đăng ký hội viên',
// href: '/admin/contact-management/membership-applications',
// },
// ],
// },
{
name: "Cấu hình chung",
href: "/admin/base-config",
icon: Settings,
permission: "settings:read",
},
{
name: "Cấu hình danh mục",
href: "/admin/header-config",
icon: Layers,
permission: "categories:read",
},
{
name: "Quản lý bài viết",
href: "/admin/news",
icon: Newspaper,
permission: "posts:read",
},
{
name: "Quản lý tag tìm kiếm",
href: "/admin/tags",
icon: Tags,
permission: "tags:read",
},
{
name: "Quản lý video",
href: "/admin/videos",
icon: Video,
permission: "videos:read",
},
{
name: "Quản lý Email đăng ký",
href: "/admin/contact-management/newsletter-emails",
icon: Mail,
permission: "newsletter:read",
},
{ name: "Quản lý ảnh", href: "/admin/media", icon: ImagePlus, permission: "files:read" },
{
name: "Quản lý quảng cáo",
href: "/admin/advertisements",
icon: Megaphone,
permission: "advertisements:read",
},
];
// Admin system menu - chỉ system_admin thấy
const adminSystemMenu: NavItem[] = [
{
name: "Quản lý vai trò",
href: "/admin/roles",
icon: Shield,
permission: "roles:read",
},
{
name: "Quản lý người dùng",
href: "/admin/users",
icon: UserCog,
permission: "users:read",
},
{
name: "Yêu cầu reset MK",
href: "/admin/password-reset-requests",
icon: KeyRound,
permission: "users:read",
},
{ name: "Quản lý ảnh", href: "/admin/media", icon: ImagePlus },
];
const membersReservedSegments = new Set(["fields", "regions"]);
......@@ -86,9 +121,9 @@ const membersReservedSegments = new Set(["fields", "regions"]);
export function AdminSidebar() {
const pathname = usePathname();
const { close, isOpen } = useSidebarStore();
const [expandedGroups, setExpandedGroups] = React.useState<
Record<string, boolean>
>({});
const [expandedGroups, setExpandedGroups] = React.useState<Record<string, boolean>>({});
const userPermissions = useAuthStore((state) => state.appUser?.permissions) || [];
const userRoles = useAuthStore((state) => state.appUser?.roles) || [];
const { data: logoData } = useGetApiV10Logo(
{
......@@ -99,14 +134,23 @@ export function AdminSidebar() {
},
{
query: {
select: (response: any) => {
const responseData = response?.responseData ?? response?.data?.responseData;
return (responseData?.rows?.[0] as Logo | undefined) ?? null;
select: (response: unknown) => {
const responseData = (response as LogoListEnvelope)?.data?.responseData;
return responseData?.rows?.[0] ?? null;
},
},
}
);
// Helper function để kiểm tra permission
const hasPermission = React.useCallback(
(permission: string | undefined) => {
if (!permission) return true;
return userPermissions.includes(permission);
},
[userPermissions]
);
const isItemActive = React.useCallback(
(href: string) => {
if (href === "/admin/members") {
......@@ -114,14 +158,12 @@ export function AdminSidebar() {
if (!pathname.startsWith(`${href}/`)) return false;
const nextSegment = pathname.slice(`${href}/`.length).split("/")[0];
return (
Boolean(nextSegment) && !membersReservedSegments.has(nextSegment)
);
return Boolean(nextSegment) && !membersReservedSegments.has(nextSegment);
}
return pathname === href || pathname.startsWith(`${href}/`);
},
[pathname],
[pathname]
);
const isGroupActive = (children: NavChild[]) =>
......@@ -134,40 +176,40 @@ export function AdminSidebar() {
if (window.innerWidth < 1024) close();
};
// Filter navigation items based on permissions
const filteredNavigation = navigation.filter((item) => hasPermission(item.permission));
// Filter admin system menu based on permissions
const filteredAdminMenu = adminSystemMenu.filter((item) => hasPermission(item.permission));
return (
<aside
className={cn(
"fixed left-0 top-0 z-40 h-dvh border-r border-[#063e8e]/10 bg-gradient-to-b from-[#f6f9ff] via-[#edf4ff] to-[#f8fbff] shadow-[0_18px_45px_rgba(6,62,142,0.08)] transition-all duration-300",
isOpen
? "w-72 translate-x-0 lg:w-72"
: "-translate-x-full lg:w-24 lg:translate-x-0",
isOpen ? "w-72 translate-x-0 lg:w-72" : "-translate-x-full lg:w-24 lg:translate-x-0"
)}
>
<div className="flex h-full flex-col">
{/* Logo Header */}
<div className={cn("px-4 pb-4 pt-5", !isOpen && "px-3")}>
<Link
href="/admin/base-config"
href="/admin"
onClick={handleMobileNavigate}
className={cn(
"flex items-center backdrop-blur-sm",
isOpen
? "gap-4 rounded-[28px] border border-white/80 bg-white/95 px-4 py-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]"
: "justify-center px-0 py-4",
: "justify-center px-0 py-4"
)}
>
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-[#063e8e]/10 bg-[#f8fbff] shadow-sm">
<Image
src={logoData?.logo_url ? resolveUploadUrl(logoData.logo_url) : fallbackLogo}
src={logoData?.logo_url ? resolveUploadUrl(logoData.logo_url) : logo}
alt={logoData?.logo_name || "VCCI HCM"}
width={40}
height={40}
className="h-10 w-10 object-contain"
priority
unoptimized={Boolean(logoData?.logo_url)}
onError={(e) => {
const img = e.currentTarget;
if (img.src !== fallbackLogo) img.src = fallbackLogo;
}}
/>
</div>
......@@ -184,6 +226,7 @@ export function AdminSidebar() {
</Link>
</div>
{/* Main Navigation */}
<div className="px-4 pb-2">
{isOpen ? (
<div className="flex items-center gap-2 px-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
......@@ -196,10 +239,10 @@ export function AdminSidebar() {
<nav
className={cn(
"scrollbar flex-1 space-y-3 overflow-y-auto px-4 pb-5 pt-2",
!isOpen && "px-3",
!isOpen && "px-3"
)}
>
{navigation.map((item) => {
{filteredNavigation.map((item) => {
if (item.children) {
const active = isGroupActive(item.children);
const expanded = expandedGroups[item.name] ?? false;
......@@ -209,9 +252,7 @@ export function AdminSidebar() {
key={item.name}
className={cn(
"rounded-[26px] border border-transparent transition-all duration-200",
isOpen &&
expanded &&
"border-[#063e8e]/10 bg-white/70 p-2 shadow-sm",
isOpen && expanded && "border-[#063e8e]/10 bg-white/70 p-2 shadow-sm"
)}
>
<button
......@@ -223,22 +264,15 @@ export function AdminSidebar() {
active
? "bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
: "text-slate-700 hover:bg-white/85 hover:text-[#063e8e]",
isOpen
? "gap-3 px-4 py-3.5"
: "mx-auto h-14 w-14 justify-center p-0",
isOpen ? "gap-3 px-4 py-3.5" : "mx-auto h-14 w-14 justify-center p-0"
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? (
<>
<span className="min-w-0 flex-1 text-left">
{item.name}
</span>
<span className="min-w-0 flex-1 text-left">{item.name}</span>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 transition-transform",
expanded && "rotate-180",
)}
className={cn("h-4 w-4 shrink-0 transition-transform", expanded && "rotate-180")}
/>
</>
) : null}
......@@ -246,25 +280,21 @@ export function AdminSidebar() {
{isOpen && expanded ? (
<div className="mt-2 space-y-1.5 border-l border-[#d5e1f7] pl-4">
{item.children.map((child) => {
const childActive = isItemActive(child.href);
return (
{item.children.map((child) => (
<Link
key={child.name}
href={child.href}
onClick={handleMobileNavigate}
className={cn(
"group relative flex rounded-2xl px-4 py-3 text-sm leading-6 transition-all",
childActive
isItemActive(child.href)
? "bg-[#dbe8ff] font-semibold text-[#063e8e]"
: "text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]",
: "text-slate-600 hover:bg-[#eef4ff] hover:text-[#063e8e]"
)}
>
<span className="block">{child.name}</span>
</Link>
);
})}
))}
</div>
) : null}
</div>
......@@ -284,20 +314,53 @@ export function AdminSidebar() {
active
? "bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
: "text-slate-700 hover:bg-white/85 hover:text-[#063e8e]",
isOpen
? "gap-3 px-4 py-3.5"
: "mx-auto h-14 w-14 justify-center p-0",
isOpen ? "gap-3 px-4 py-3.5" : "mx-auto h-14 w-14 justify-center p-0"
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? <span className="min-w-0 flex-1">{item.name}</span> : null}
</Link>
);
})}
{/* Admin System Menu - Chỉ system_admin thấy */}
{filteredAdminMenu.length > 0 && (
<>
<div className="border-t border-[#063e8e]/10 pt-3" />
{isOpen ? (
<span className="min-w-0 flex-1">{item.name}</span>
<div className="flex items-center gap-2 px-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#063e8e]">
<Shield className="h-3.5 w-3.5" />
Quản trị hệ thống
</div>
) : null}
{filteredAdminMenu.map((item) => {
const active = item.href ? isItemActive(item.href) : false;
return (
<Link
key={item.name}
href={item.href || "#"}
onClick={handleMobileNavigate}
title={!isOpen ? item.name : undefined}
className={cn(
"flex items-center rounded-2xl text-sm font-medium transition-all duration-200",
active
? "bg-[#063e8e] text-white shadow-[0_12px_24px_rgba(6,62,142,0.18)]"
: "text-slate-700 hover:bg-white/85 hover:text-[#063e8e]",
isOpen ? "gap-3 px-4 py-3.5" : "mx-auto h-14 w-14 justify-center p-0"
)}
>
<item.icon className="h-5 w-5 shrink-0" />
{isOpen ? <span className="min-w-0 flex-1">{item.name}</span> : null}
</Link>
);
})}
</>
)}
</nav>
{/* Footer */}
<div className="px-4 pb-5 pt-3">
{isOpen ? (
<div className="rounded-[28px] border border-white/80 bg-white/95 p-4 shadow-[0_14px_32px_rgba(6,62,142,0.08)]">
......@@ -311,9 +374,7 @@ export function AdminSidebar() {
</div>
<div>
<div>Về trang chủ</div>
<div className="mt-0.5 text-xs font-medium text-slate-500">
Website công khai
</div>
<div className="mt-0.5 text-xs font-medium text-slate-500">Website công khai</div>
</div>
</Link>
<div className="mt-3 border-t border-slate-100 pt-3 text-xs text-slate-500">
......
"use client";
import React from "react";
import { usePermission } from "@/hooks/usePermission";
interface PermissionGateProps {
children: React.ReactNode;
/** Một permission duy nhất, ví dụ: "posts:write" HOẶC resource + action riêng */
required?: string;
/** Resource khi dùng cùng với requiredAction */
resource?: string;
/** Action khi dùng cùng với requiredResource */
action?: string;
/** Nhiều permissions - cần tất cả (AND) hoặc ít nhất một (OR) */
requires?: string[];
/** Mode khi dùng requires: "all" = cần tất cả, "any" = cần ít nhất một */
mode?: "all" | "any";
/** Component thay thế khi không có quyền */
fallback?: React.ReactNode;
/** Ẩn hoàn toàn khi không có quyền (mặc định: false - hiển thị fallback) */
hidden?: boolean;
}
/**
* Component để kiểm soát hiển thị dựa trên permissions
*
* @example
* // Kiểm tra một permission
* <PermissionGate required="posts:write">
* <Button>Create Post</Button>
* </PermissionGate>
*
* // Kiểm tra với resource + action riêng
* <PermissionGate resource="posts" action="write">
* <Button>Create Post</Button>
* </PermissionGate>
*
* // Cần nhiều permissions
* <PermissionGate requires={["posts:write", "posts:delete"]} mode="all">
* <Button>Full Post Control</Button>
* </PermissionGate>
*
* // Hiển thị fallback khi không có quyền
* <PermissionGate required="posts:delete" fallback={<DisabledButton />}>
* <Button>Delete</Button>
* </PermissionGate>
*/
export function PermissionGate({
children,
required,
resource,
action,
requires,
mode = "all",
fallback = null,
hidden = false,
}: PermissionGateProps) {
// Xác định permission cuối cùng
let finalPermission: string | string[];
if (required) {
finalPermission = required;
} else if (resource && action) {
finalPermission = `${resource}:${action}`;
} else if (requires) {
finalPermission = requires;
} else {
console.warn("[PermissionGate] Missing permission configuration");
return hidden ? null : <>{fallback}</>;
}
// Kiểm tra permission với hook
// Khi finalPermission là string có dấu ":" (vd "roles:write"), truyền 1 tham số
// Khi là string không có ":" + có resource/action, truyền 2 tham số riêng
// Khi là array, truyền array + mode
const hasPermission = usePermission(
finalPermission,
Array.isArray(finalPermission) ? mode : undefined,
);
if (hasPermission) {
return <>{children}</>;
}
if (hidden) {
return null;
}
return <>{fallback}</>;
}
/**
* Component để hiển thị thông báo khi không có quyền
* Sử dụng khi toàn bộ trang bị chặn
*/
export function NoPermissionMessage({
message = "Tài khoản của bạn hiện không có quyền hạn để truy cập trang này. Vui lòng liên hệ bộ phận kỹ thuật để được hỗ trợ.",
showContactButton = true,
}: {
message?: string;
showContactButton?: boolean;
}) {
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 text-center">
<div className="mb-6 rounded-full bg-red-50 p-6">
<svg
className="h-16 w-16 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<h2 className="mb-3 text-2xl font-bold text-gray-900">Không có quyền truy cập</h2>
<p className="mb-8 max-w-md text-gray-600">{message}</p>
{showContactButton && (
<div className="flex gap-4">
<button
onClick={() => window.history.back()}
className="rounded-xl border border-gray-300 px-6 py-3 font-medium text-gray-700 transition-colors hover:bg-gray-50"
>
Quay lại
</button>
<a
href="/admin"
className="rounded-xl bg-[#063e8e] px-6 py-3 font-medium text-white transition-colors hover:bg-[#063e8e]/90"
>
Về trang chính
</a>
</div>
)}
</div>
);
}
/**
* Component để redirect khi không có quyền
* Sử dụng trong các page components
*/
export function PermissionRedirect({
required,
redirectTo = "/admin/no-permission",
}: {
required: string | string[];
redirectTo?: string;
}) {
const hasPermission = usePermission(
Array.isArray(required) ? required : [required],
"any",
);
React.useEffect(() => {
if (!hasPermission) {
window.location.href = redirectTo;
}
}, [hasPermission, redirectTo]);
if (!hasPermission) {
return null;
}
return null;
}
'use client';
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useAdvertisements } from "@/app/(main)/(home)/lib/use-advertisements";
import { resolveUploadUrl } from "@/links";
import type { Advertisement } from "@/api/models/advertisement";
const FALLBACK_SRC = "/quang-cao/qc-3.jpg";
const FALLBACK_HREF = "https://vcci-hcm.org.vn";
function SidebarAdItem({ item }: { item: Advertisement }) {
const initialSrc = item.file?.path ? resolveUploadUrl(item.file.path) : FALLBACK_SRC;
const [src, setSrc] = useState(initialSrc);
const isGif = src.toLowerCase().endsWith(".gif");
return (
<Link
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title={item.name}
>
<div className="relative aspect-[16/10]">
<Image
src={src}
alt={item.alt || item.name}
fill
className="h-full w-full object-cover"
unoptimized={isGif}
onError={() => {
if (src !== FALLBACK_SRC) setSrc(FALLBACK_SRC);
}}
/>
</div>
</Link>
);
}
function FallbackSidebarAdItem() {
return (
<Link
href={FALLBACK_HREF}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-[22px] shadow-[0_18px_42px_rgba(17,24,39,0.12)]"
title="Quảng cáo VCCI HCM"
>
<div className="relative aspect-[16/10]">
<Image
src={FALLBACK_SRC}
alt="Quảng cáo VCCI HCM"
fill
className="h-full w-full object-cover"
/>
</div>
</Link>
);
}
/** Sidebar quảng cáo vuông (stack dọc) cho các trang detail/catalog/search */
function SidebarAdvertisements({ count = 5, startIndex = 0 }: { count?: number; startIndex?: number }) {
const ads = useAdvertisements("square");
const visibleAds = ads.slice(startIndex, startIndex + count);
// Fallback: nếu API không có data, hiển thị fallback items
const items =
visibleAds.length > 0
? visibleAds.map((item) => <SidebarAdItem key={item.id} item={item} />)
: Array.from({ length: count }).map((_, i) => <FallbackSidebarAdItem key={`fallback-${i}`} />);
return (
<div className="order-3 flex flex-col gap-4 xl:order-none">
{items}
</div>
);
}
export default SidebarAdvertisements;
"use client";
import { useMemo } from "react";
import useAuthStore from "@/store/useAuthStore";
/**
* Hook để kiểm tra permission của user hiện tại
*
* @example
* // Kiểm tra một permission cụ thể
* const canWritePosts = usePermission("posts", "write");
*
* // Kiểm tra nhiều permissions (AND - cần tất cả)
* const canManagePosts = usePermission(["posts:write", "posts:delete"], "all");
*
* // Kiểm tra nhiều permissions (OR - cần ít nhất một)
* const canDoSomething = usePermission(["posts:read", "posts:write"], "any");
*/
export function usePermission(
resourceOrPermissions: string | string[],
actionOrMode?: string,
): boolean {
const appUser = useAuthStore((state) => state.appUser);
return useMemo(() => {
const userPermissions = appUser?.permissions || [];
// Case 1: Truyền vào resource và action riêng biệt
// usePermission("posts", "write") -> kiểm tra "posts:write"
if (typeof resourceOrPermissions === "string" && typeof actionOrMode === "string") {
const permission = `${resourceOrPermissions}:${actionOrMode}`;
return userPermissions.includes(permission);
}
// Case 2: Truyền vào array permissions với mode
if (Array.isArray(resourceOrPermissions)) {
const mode = actionOrMode as "all" | "any" | undefined;
if (mode === "all") {
// Cần TẤT CẢ permissions trong array
return resourceOrPermissions.every((perm) => userPermissions.includes(perm));
} else if (mode === "any") {
// Cần ÍT NHẤT MỘT permission trong array
return resourceOrPermissions.some((perm) => userPermissions.includes(perm));
} else {
// Default: kiểm tra như "all" (cần tất cả)
return resourceOrPermissions.every((perm) => userPermissions.includes(perm));
}
}
// Case 3: Truyền vào một permission string trực tiếp
// usePermission("posts:write")
if (typeof resourceOrPermissions === "string") {
return userPermissions.includes(resourceOrPermissions);
}
return false;
}, [appUser, resourceOrPermissions, actionOrMode]);
}
/**
* Hook để kiểm tra user có role cụ thể hay không
*
* @example
* const isAdmin = useHasRole("admin");
* const isSystemAdmin = useHasRole("system_admin");
*/
export function useHasRole(roleName: string | string[]): boolean {
const appUser = useAuthStore((state) => state.appUser);
return useMemo(() => {
const userRoles = appUser?.roles || [];
if (Array.isArray(roleName)) {
return roleName.some((role) => userRoles.includes(role));
}
return userRoles.includes(roleName);
}, [appUser, roleName]);
}
/**
* Hook để lấy tất cả permissions của user hiện tại
*
* @example
* const permissions = useAllPermissions();
*/
export function useAllPermissions(): string[] {
const appUser = useAuthStore((state) => state.appUser);
return appUser?.permissions || [];
}
/**
* Hook để lấy tất cả roles của user hiện tại
*
* @example
* const roles = useAllRoles();
*/
export function useAllRoles(): string[] {
const appUser = useAuthStore((state) => state.appUser);
return appUser?.roles || [];
}
/**
* Hook để kiểm tra user có bất kỳ quyền hạn nào không
*
* @example
* const hasAnyPermission = useHasAnyPermission();
*/
export function useHasAnyPermission(): boolean {
const appUser = useAuthStore((state) => state.appUser);
return (appUser?.permissions?.length || 0) > 0;
}
/**
* Hook để kiểm tra user có phải là system_admin không
*
* @example
* const isSystemAdmin = useIsSystemAdmin();
*/
export function useIsSystemAdmin(): boolean {
return useHasRole("system_admin");
}
/**
* Hook để kiểm tra user có phải là admin (admin hoặc system_admin) không
*
* @example
* const isAdmin = useIsAdmin();
*/
export function useIsAdmin(): boolean {
return useHasRole(["admin", "system_admin"]);
}
......@@ -36,6 +36,7 @@ export interface CmsFileItem {
export interface CmsPostContentImage {
position: number;
caption: string;
image: {
id: string;
name: string;
......@@ -277,6 +278,7 @@ const parsePostContent = (contentStructure?: Record<string, unknown> | null): Cm
image_rows: typeof section.image_rows === "number" ? section.image_rows : 2,
images: images.map((image, imageIndex) => ({
position: typeof image.position === "number" ? image.position : imageIndex + 1,
caption: typeof image.caption === "string" ? image.caption : "",
image: {
id: typeof image.image === "object" && image.image && "id" in image.image
? String((image.image as Record<string, unknown>).id ?? "")
......
......@@ -37,6 +37,7 @@ interface LoginResponseData {
refresh_token?: string | null;
expires_in?: number | null;
token_type?: string | null;
must_change_password?: boolean | null;
}
interface LoginResponseEnvelope {
......@@ -104,6 +105,7 @@ const normalizeUser = (user?: Partial<AuthenticatedAdminUser> | null): Authentic
: [],
status: user.status ?? null,
last_login_at: user.last_login_at ?? null,
must_change_password: user.must_change_password === true,
};
};
......@@ -181,7 +183,12 @@ const redirectToLogin = () => {
if (typeof window === "undefined") return;
const currentPath = `${window.location.pathname}${window.location.search}`;
const redirect = currentPath.startsWith("/admin") && currentPath !== "/admin/login"
// Không bao giờ redirect về trang đổi mật khẩu sau khi login lại
const isChangePasswordPage = currentPath.startsWith("/admin/change-password");
const redirect =
currentPath.startsWith("/admin") &&
currentPath !== "/admin/login" &&
!isChangePasswordPage
? `?redirect=${encodeURIComponent(currentPath)}`
: "";
......@@ -222,7 +229,23 @@ export async function loginAdmin(
authToken: loginData.access_token,
}).catch(() => loginData.user ?? null);
const normalizedUser = normalizeUser(me ?? loginData.user);
// Merge user data: ưu tiên permissions từ login response nếu /me trả về rỗng
const mergedUser = me
? {
...me,
// Nếu /me trả về permissions rỗng, dùng permissions từ login response
permissions: (me as { permissions?: unknown[] }).permissions?.length
? me.permissions
: loginData.user?.permissions,
// must_change_password đến từ login response (BE), không có trong /me
must_change_password: loginData.must_change_password === true,
}
: {
...loginData.user,
must_change_password: loginData.must_change_password === true,
} as Partial<AuthenticatedAdminUser>;
const normalizedUser = normalizeUser(mergedUser);
useAuthStore.getState().setAuthSession({
accessToken: loginData.access_token,
......@@ -300,6 +323,15 @@ export async function refreshAdminAccessToken() {
return null;
}
// Refresh token đã hết hạn → không gọi API, logout ngay
if (store.appSession?.refresh_expires_at) {
const refreshExpiredAt = new Date(store.appSession.refresh_expires_at).getTime();
if (Number.isFinite(refreshExpiredAt) && refreshExpiredAt <= Date.now()) {
await logoutAdmin({ silent: true, reason: "missing_refresh_token" });
return null;
}
}
store.setAppRefreshing(true);
try {
......@@ -341,25 +373,47 @@ export async function refreshAdminAccessToken() {
export async function ensureValidAdminAccessToken() {
const store = useAuthStore.getState();
// No access token at all — try refresh if we have a usable refresh token
if (!store.appAccessToken) {
if (store.appRefreshToken) {
if (store.appRefreshToken && isRefreshTokenUsable(store.appSession)) {
return refreshAdminAccessToken();
}
// No usable refresh token — logout
if (store.appIsLoggedIn) {
await logoutAdmin({ silent: true, reason: "missing_refresh_token" });
}
return null;
}
if (!store.appAccessTokenExpired || store.appAccessTokenExpired > Date.now()) {
const jwtExpiresAt = getJwtExpiresAt(store.appAccessToken);
// Determine expiry: prefer stored appAccessTokenExpired, fallback to JWT exp
const expiryMs =
store.appAccessTokenExpired ?? getJwtExpiresAt(store.appAccessToken);
if (!jwtExpiresAt || jwtExpiresAt > Date.now()) {
// If we can't determine expiry, assume token is still valid (let 401 handle it)
if (expiryMs === null) {
return store.appAccessToken;
}
// Token still valid — use it
if (expiryMs > Date.now()) {
return store.appAccessToken;
}
// Token expired — need refresh; check refresh token usability first
if (!store.appRefreshToken || !isRefreshTokenUsable(store.appSession)) {
await logoutAdmin({ silent: true, reason: "missing_refresh_token" });
return null;
}
return refreshAdminAccessToken();
}
const isRefreshTokenUsable = (session: AuthenticatedAdminSession | null): boolean => {
if (!session?.refresh_expires_at) return true; // không biết → cho phép thử
const time = new Date(session.refresh_expires_at).getTime();
return Number.isFinite(time) && time > Date.now();
};
export async function handleAdminUnauthorized() {
await logoutAdmin({ silent: true, reason: "refresh_failed" });
}
......
......@@ -43,6 +43,7 @@ export interface AdminNewsImageRef {
export interface AdminNewsContentImage {
position: number;
image: AdminNewsImageRef;
caption: string;
}
export interface AdminNewsContentSection {
......@@ -256,8 +257,8 @@ const newsSeed: AdminNewsItem[] = [
image_columns: 2,
image_rows: 1,
images: [
{ position: 1, image: toImageRef(mediaSeed[2]) },
{ position: 2, image: toImageRef(mediaSeed[3]) },
{ position: 1, image: toImageRef(mediaSeed[2]), caption: "" },
{ position: 2, image: toImageRef(mediaSeed[3]), caption: "" },
],
},
],
......@@ -337,7 +338,7 @@ const newsSeed: AdminNewsItem[] = [
content: "",
image_columns: 1,
image_rows: 1,
images: [{ position: 1, image: toImageRef(mediaSeed[0]) }],
images: [{ position: 1, image: toImageRef(mediaSeed[0]), caption: "" }],
},
],
},
......@@ -1327,6 +1328,7 @@ export function cloneAdminNewsFormValues(item?: AdminNewsItem | null): AdminNews
...section,
images: section.images.map((image) => ({
...image,
caption: image.caption ?? "",
image: { ...image.image },
})),
})),
......
"use client";
export const ADVERTISEMENTS_STORAGE_KEY = "vcci-news.admin-advertisements.data.v1";
/** Loại quảng cáo: vuông (sidebar) hoặc ngang (banner full-width) */
export type AdvertisementType = "square" | "horizontal";
/** Chế độ hiển thị */
export type AdvertisementStatus = "active" | "inactive";
export interface AdvertisementItem {
id: string;
/** Tên quảng cáo */
name: string;
/** Đường dẫn ảnh (có thể là .gif) */
image: string;
/** Alt text cho ảnh */
alt: string;
/** Đường link khi bấm vào */
link: string;
/** Loại quảng cáo: vuông | ngang */
type: AdvertisementType;
/** Chế độ hiển thị */
status: AdvertisementStatus;
/** Thứ tự hiển thị (số nhỏ hiện trước) */
sortOrder: number;
/** Ngày tạo (ISO string) */
createdAt: string;
/** Tạo bởi ai */
createdBy: string;
/** Ngày sửa (ISO string) */
updatedAt: string;
/** Sửa bởi ai */
updatedBy: string;
}
export interface AdvertisementFormValues {
id?: string;
name: string;
image: string;
alt: string;
link: string;
type: AdvertisementType;
status: AdvertisementStatus;
sortOrder: number;
}
const ADVERTISEMENT_SEED: AdvertisementItem[] = [
// === Loại ngang (banner full-width giữa các section) ===
{
id: "ad-h-001",
name: "Quảng cáo ngang VCCI HCM 1",
image: "/quang-cao/qc-1.jpg",
alt: "Quảng cáo VCCI HCM",
link: "https://vcci-hcm.org.vn1",
type: "horizontal",
status: "active",
sortOrder: 1,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
{
id: "ad-h-002",
name: "Quảng cáo ngang VCCI HCM 2",
image: "/quang-cao/qc-1.jpg",
alt: "Quảng cáo VCCI HCM",
link: "https://vcci-hcm.org.vn2",
type: "horizontal",
status: "active",
sortOrder: 2,
createdAt: "2025-01-06T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
// === Loại vuông (sidebar cạnh News / Business) ===
{
id: "ad-s-001",
name: "Quảng cáo sidebar 1",
image: "/quang-cao/qc-2.gif",
alt: "Quảng cáo 1",
link: "https://hardwaretools.com.vn/1",
type: "square",
status: "active",
sortOrder: 1,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
{
id: "ad-s-002",
name: "Quảng cáo sidebar 2",
image: "/quang-cao/qc-2.gif",
alt: "Quảng cáo 2",
link: "https://hardwaretools.com.vn/2",
type: "square",
status: "active",
sortOrder: 2,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
{
id: "ad-s-003",
name: "Quảng cáo sidebar 3",
image: "/quang-cao/qc-2.gif",
alt: "Quảng cáo 3",
link: "https://hardwaretools.com.vn/3",
type: "square",
status: "active",
sortOrder: 3,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
{
id: "ad-s-004",
name: "Quảng cáo sidebar 4",
image: "/quang-cao/qc-2.gif",
alt: "Quảng cáo 4",
link: "https://hardwaretools.com.vn/4",
type: "square",
status: "active",
sortOrder: 4,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
{
id: "ad-s-005",
name: "Quảng cáo sidebar 5",
image: "/quang-cao/qc-2.gif",
alt: "Quảng cáo 5",
link: "https://hardwaretools.com.vn/5",
type: "square",
status: "active",
sortOrder: 5,
createdAt: "2025-01-05T00:00:00.000Z",
createdBy: "admin",
updatedAt: "2025-01-13T08:34:50.000Z",
updatedBy: "admin",
},
];
export function createAdvertisementId(): string {
return `ad-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function getAdvertisementSeed(): AdvertisementItem[] {
return ADVERTISEMENT_SEED.map((item) => ({ ...item }));
}
/** Đọc tất cả quảng cáo từ localStorage, fallback seed */
export function readAdvertisements(): AdvertisementItem[] {
if (typeof window === "undefined") return getAdvertisementSeed();
const raw = window.localStorage.getItem(ADVERTISEMENTS_STORAGE_KEY);
if (!raw) return getAdvertisementSeed();
try {
const parsed = JSON.parse(raw) as AdvertisementItem[];
return Array.isArray(parsed) && parsed.length > 0 ? parsed : getAdvertisementSeed();
} catch {
return getAdvertisementSeed();
}
}
/** Đọc quảng cáo theo loại (square | horizontal), chỉ lấy active, sắp xếp theo sortOrder */
export function readAdvertisementsByType(type: AdvertisementType): AdvertisementItem[] {
return readAdvertisements()
.filter((item) => item.type === type && item.status === "active")
.sort((a, b) => a.sortOrder - b.sortOrder);
}
export function persistAdvertisements(items: AdvertisementItem[]): void {
if (typeof window === "undefined") return;
window.localStorage.setItem(ADVERTISEMENTS_STORAGE_KEY, JSON.stringify(items));
}
export function sortAdvertisements(items: AdvertisementItem[]): AdvertisementItem[] {
return [...items].sort((a, b) => {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
return a.name.localeCompare(b.name, "vi");
});
}
export const EMPTY_ADVERTISEMENT_FORM: AdvertisementFormValues = {
name: "",
image: "",
alt: "",
link: "",
type: "square",
status: "active",
sortOrder: 1,
};
......@@ -11,6 +11,7 @@ export interface AuthenticatedAdminUser {
permissions: string[];
status: string | null;
last_login_at: string | null;
must_change_password?: boolean;
}
export interface AuthenticatedAdminSession {
......
......@@ -58,7 +58,7 @@
/* Primary colors for <Button /> */
--primary: 215 92% 29%;
--primary-foreground: 50 82% 50%;
--primary-foreground: 0 0% 100%;
--primary-orange: 36 100% 55%;
/* Secondary colors for <Button /> */
......@@ -67,7 +67,7 @@
/* Muted backgrounds such as <TabsList />, <Skeleton /> and <Switch /> */
--muted: 0 0% 91%;
--muted-foreground: 50 82% 50%;
--muted-foreground: 0 0% 45%;
/* Used for accents such as hover effects on <DropdownMenuItem>, <SelectItem>...etc */
--accent: 175 32% 92%;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment