first commit

This commit is contained in:
nkhangg 2025-03-17 10:31:19 +07:00
commit d819ea9850
135 changed files with 28765 additions and 0 deletions

25
auto-bid-admin/.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

50
auto-bid-admin/README.md Normal file
View File

@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
auto-bid-admin/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7593
auto-bid-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,62 @@
{
"name": "react-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mantine/charts": "^7.17.0",
"@mantine/core": "^7.16.3",
"@mantine/dates": "^7.16.3",
"@mantine/dropzone": "^7.16.3",
"@mantine/form": "^7.16.3",
"@mantine/hooks": "^7.16.3",
"@tabler/icons-react": "^3.30.0",
"@tailwindcss/vite": "^4.0.6",
"@uiw/react-json-view": "^2.0.0-alpha.30",
"@uiw/react-md-editor": "^4.0.5",
"@vitejs/plugin-react": "^4.3.4",
"axios": "^1.7.9",
"dompurify": "^3.2.4",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"react": "^19.0.0",
"react-cropper": "^2.3.3",
"react-dom": "^19.0.0",
"react-router": "^6.29.0",
"react-router-dom": "^6.29.0",
"react-toastify": "^11.0.3",
"react-use-websocket": "^4.13.0",
"rehype-sanitize": "^6.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.0.1",
"tailwindcss": "^4.0.6",
"uuid": "^11.0.5",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@types/lodash": "^4.17.15",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"vite": "^6.1.0"
}
}

View File

@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

View File

@ -0,0 +1,64 @@
import { handleError, handleSuccess } from '.';
import axios, { IResponse } from '../lib/axios';
import { IAdmin } from '../system/type';
export const login = async (credentials: { username: string; password: string }) => {
try {
const { data } = await axios({
url: 'auth/login',
data: credentials,
withCredentials: true,
method: 'POST',
});
handleSuccess(data);
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
};
export const logout = async () => {
try {
const { data } = await axios({
url: 'auth/logout',
withCredentials: true,
method: 'POST',
});
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
};
export const changePassword = async (credentials: { newPassword: string; password: string }) => {
try {
const { data } = await axios({
url: 'auth/change-password',
withCredentials: true,
method: 'POST',
data: credentials,
});
handleSuccess(data);
return data as IResponse<boolean>;
} catch (error) {
handleError(error);
}
};
export const me = async () => {
try {
const { data } = await axios({
url: 'auth/me',
method: 'GET',
withCredentials: true,
});
return data as IResponse<IAdmin>;
} catch (error) {
handleError(error);
}
};

View File

@ -0,0 +1,106 @@
import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios';
import { IBid } from '../system/type';
import { removeFalsyValues } from '../utils';
export const getBids = async (params: Record<string, string | number>) => {
return await axios({
url: 'bids',
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
});
};
export const createBid = async (bid: Omit<IBid, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
const newData = removeFalsyValues(bid);
try {
const { data } = await axios({
url: 'bids',
withCredentials: true,
method: 'POST',
data: newData,
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const updateBid = async (bid: Partial<IBid>) => {
const { step_price, max_price, quantity } = removeFalsyValues(bid);
try {
const { data } = await axios({
url: 'bids/' + bid.id,
withCredentials: true,
method: 'PUT',
data: { step_price, max_price, quantity },
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const toggleBid = async (bid: Partial<IBid>) => {
try {
const { data } = await axios({
url: 'bids/toggle/' + bid.id,
withCredentials: true,
method: 'POST',
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deleteBid = async (bid: IBid) => {
try {
const { data } = await axios({
url: 'bids/' + bid.id,
withCredentials: true,
method: 'DELETE',
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deletesBid = async (bids: IBid[]) => {
const ids = bids.reduce((prev, cur) => {
prev.push(cur.id);
return prev;
}, [] as number[]);
try {
const { data } = await axios({
url: 'bids/deletes',
withCredentials: true,
method: 'POST',
data: {
ids,
},
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -0,0 +1,66 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AxiosError, HttpStatusCode } from 'axios';
import { toast } from 'react-toastify';
import { IResponse } from '../lib/axios';
import { ITableFilter } from '../lib/table/type';
import { defaultPrefixShort, searchKey } from '../lib/table/ultils';
export const handleError = (error: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = (error as AxiosError).response as Record<string, any>;
const data = response.data;
if (response.status === HttpStatusCode.Forbidden) return;
if (data?.errors && (data.errors as []).length) {
let newMessage = data.errors[0].errors[0];
if ((data.errors as []).length > 1) {
newMessage = newMessage + ` and ${data.errors.length} errors`;
}
toast.error(newMessage || 'Internal Server Error');
return;
}
toast.error((response?.data.message as string) || 'Internal Server Error');
};
export const handleSuccess = <R>(data: IResponse<R>) => {
toast.success(data.message);
};
export function parseFilterToParams<T extends Record<string, any>>(data: ITableFilter<T>[]) {
if (!data) return null;
return data.reduce((acc, item) => {
acc[item.key] = item.type;
return acc;
}, {} as Record<string, any>);
}
export function generateNestParams(params: Record<string, any>) {
const excludeKeys = ['page'];
const prefixSortKey = defaultPrefixShort;
if (!params) return params;
const newParams = Object.keys(params).reduce((prev, cur) => {
if (excludeKeys.includes(cur)) {
prev[cur] = params[cur];
} else if (cur.includes(prefixSortKey)) {
prev['sortBy'] = `${cur.replace(defaultPrefixShort, '')}:${String(params[cur]).toLocaleUpperCase()}`;
} else if (cur === 'per_page') {
prev['limit'] = params[cur];
} else if (cur === searchKey) {
prev['search'] = params[cur];
} else {
prev[`filter.${cur}`] = params[cur];
}
return prev;
}, {} as Record<string, any>);
return newParams;
}

View File

@ -0,0 +1,11 @@
import { generateNestParams } from '.';
import axios from '../lib/axios';
export const getOutBidLogs = async (params: Record<string, string | number>) => {
return await axios({
url: 'out-bid-logs',
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
});
};

View File

@ -0,0 +1,92 @@
import { generateNestParams, handleError, handleSuccess } from '.';
import axios from '../lib/axios';
import { IWebBid } from '../system/type';
import { removeFalsyValues } from '../utils';
const BASE_URL = 'web-bids';
export const getWebBids = async (params: Record<string, string | number>) => {
return await axios({
url: BASE_URL,
params: generateNestParams(params),
withCredentials: true,
method: 'GET',
});
};
export const createWebBid = async (bid: Omit<IWebBid, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>) => {
const newData = removeFalsyValues(bid);
try {
const { data } = await axios({
url: BASE_URL,
withCredentials: true,
method: 'POST',
data: newData,
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const updateWebBid = async (bid: Partial<IWebBid>) => {
const { url, password, username, origin_url, active } = removeFalsyValues(bid, ['active']);
try {
const { data } = await axios({
url: `${BASE_URL}/` + bid.id,
withCredentials: true,
method: 'PUT',
data: { url, password, username, origin_url, active },
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deleteWebBid = async (web: IWebBid) => {
try {
const { data } = await axios({
url: `${BASE_URL}/` + web.id,
withCredentials: true,
method: 'DELETE',
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};
export const deletesWebBid = async (web: IWebBid[]) => {
const ids = web.reduce((prev, cur) => {
prev.push(cur.id);
return prev;
}, [] as number[]);
try {
const { data } = await axios({
url: `${BASE_URL}/deletes`,
withCredentials: true,
method: 'POST',
data: {
ids,
},
});
handleSuccess(data);
return data;
} catch (error) {
handleError(error);
}
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,165 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Badge, Box, Button, Modal, ModalProps, PasswordInput, Text, TextInput, Tooltip } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconPlus } from '@tabler/icons-react';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import { z } from 'zod';
import { createAdmin, updateAdmin } from '../../apis/admin';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { usePermissionStore } from '../../lib/zustand/use-permission-store';
import { mappingPermissionsColors } from '../../system/constants';
import { IAdmin } from '../../system/type';
import PermissionDrawer from './permission-drawer';
export interface IAdminModelProps extends ModalProps {
data: IAdmin | null;
onUpdated?: () => void;
}
const baseSchema = {
email: z.string().email('Invalid email format').optional(),
fullname: z.string().min(3, 'Fullname must be at least 3 characters').nullable().optional(),
};
const updateSchema = z.object(baseSchema);
const createSchema = z
.object({
...baseSchema,
password: z.string({ message: 'Password is required' }).min(6, 'New password must be at least 6 characters'),
confirmPassword: z.string({ message: 'Confirm password is required' }),
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export default function AdminModal({ data, onUpdated, ...props }: IAdminModelProps) {
const form = useForm({
validate: zodResolver(data ? updateSchema : createSchema),
});
const [opened, { open, close }] = useDisclosure(false);
const { deletePermission, setPermissions, permissions, basePermission } = usePermissionStore();
const prevData = useRef<IAdmin | null>(data);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This account will be update`,
handleOk: async () => {
const result = await updateAdmin(values);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
const { confirmPassword, ...newValues } = values;
const result = await createAdmin(newValues as Omit<IAdmin, 'id' | 'created_at' | 'updated_at' | 'is_system_account'>);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
setPermissions(data.permissions);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
useEffect(() => {
form.setFieldValue('permissions', permissions);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions]);
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Admin</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
<TextInput readOnly={!!data} size="sm" label="Username" {...form.getInputProps('username')} />
<TextInput size="sm" label="Email" {...form.getInputProps('email')} />
<TextInput className="col-span-2" size="sm" label="Fullname" {...form.getInputProps('fullname')} />
{!data && (
<>
<PasswordInput size="sm" label="Password" {...form.getInputProps('password')} />
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
</>
)}
{!data ||
(!data.is_system_account && (
<Box className="col-end-2">
<Text className="font-semibold text-sm">Permission</Text>
<Box className="flex items-center gap-2">
{permissions.map((item) => {
return (
<Tooltip key={item.name} label={item.description}>
<Badge onClick={() => deletePermission(item)} color={mappingPermissionsColors[item.name as keyof typeof mappingPermissionsColors]}>
{item.name}
</Badge>
</Tooltip>
);
})}
{permissions.length !== basePermission.length && (
<Button onClick={open} leftSection={<IconPlus size={14} />} size="xs">
Add
</Button>
)}
</Box>
</Box>
))}
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
</Button>
</form>
<PermissionDrawer opened={opened} onClose={close} />
</Modal>
);
}

View File

@ -0,0 +1,94 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, Modal, ModalProps, PasswordInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useEffect } from 'react';
import { z } from 'zod';
import { grantNewPasswordAdmin } from '../../apis/admin';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IAdmin } from '../../system/type';
export interface IAdminModelProps extends ModalProps {
data: IAdmin | null;
onUpdated?: () => void;
}
const schema = z
.object({
password: z.string({ message: 'Password is required' }).min(6, 'New password must be at least 6 characters'),
confirmPassword: z.string({ message: 'Confirm password is required' }),
})
.refine((data) => data.password === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export default function GrantNewPasswordModal({ data, onUpdated, ...props }: IAdminModelProps) {
const form = useForm({
validate: zodResolver(schema),
});
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This account will be update`,
handleOk: async () => {
const result = await grantNewPasswordAdmin({
id: data.id,
password: values.password,
});
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Grant !',
},
});
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
size={'xl'}
{...props}
title={<span className="text-xl font-bold">Grant password</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
<PasswordInput className="col-span-1" size="sm" label="Password" {...form.getInputProps('password')} />
<PasswordInput className="col-span-1" size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
<Button className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{'Grant'}
</Button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,3 @@
export { default as AdminModal } from './admin-modal';
export { default as PermissionDrawer } from './permission-drawer';
export { default as GrantNewPasswordModal } from './grant-new-password-modal';

View File

@ -0,0 +1,35 @@
import { Box, Button, Drawer, DrawerProps, Tooltip } from '@mantine/core';
import _ from 'lodash';
import { usePermissions } from '../../hooks';
import { usePermissionStore } from '../../lib/zustand/use-permission-store';
import { mappingPermissionsColors } from '../../system/constants';
import { IconCheck, IconPlus } from '@tabler/icons-react';
export default function PermissionDrawer({ ...props }: DrawerProps) {
const permissionData = usePermissions();
const { permissions, setPermission, deletePermission } = usePermissionStore();
return (
<Drawer {...props} title="Permission">
<Box className="flex items-center gap-3.5 flex-wrap">
{permissionData.map((item) => {
const isChecked = _.some(permissions, item);
return (
<Tooltip key={item.name} label={item.description}>
<Button
leftSection={!isChecked ? <IconPlus size={14} /> : <IconCheck size={14} />}
onClick={() => (isChecked ? deletePermission(item) : setPermission(item))}
size="xs"
color={mappingPermissionsColors[item.name as keyof typeof mappingPermissionsColors]}
>
{item.name}
</Button>
</Tooltip>
);
})}
</Box>
</Drawer>
);
}

View File

@ -0,0 +1,106 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, Modal, ModalProps, NumberInput, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import { z } from 'zod';
import { createBid, updateBid } from '../../apis/bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IBid } from '../../system/type';
export interface IBidModelProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
const schema = {
url: z.string({ message: 'Url is required' }).url('Invalid url format'),
max_price: z.number({ message: 'Max price is required' }).min(1, 'Max price must be at least 1'),
step_price: z.number().min(0, 'Step price must be at least 1').optional(),
quantity: z.number().min(1, 'Quantity must be at least 1').optional(),
};
export default function BidModal({ data, onUpdated, ...props }: IBidModelProps) {
const form = useForm({
validate: zodResolver(z.object(schema)),
});
const prevData = useRef<IBid | null>(data);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This product will be update`,
handleOk: async () => {
const result = await updateBid(values);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
const { url, max_price, step_price } = values;
const result = await createBid({ url, max_price, step_price } as IBid);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Bid</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
<TextInput className="col-span-2" readOnly={!!data} size="sm" label="Url" {...form.getInputProps('url')} />
<NumberInput className="col-span-2" size="sm" label="Max price" {...form.getInputProps('max_price')} />
<NumberInput size="sm" label="Step price" {...form.getInputProps('step_price')} />
<NumberInput size="sm" label="Quantity" {...form.getInputProps('quantity')} />
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
</Button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,2 @@
export { default as ShowHistoriesModal } from './show-histories-modal';
export { default as BidModal } from './bid-modal';

View File

@ -0,0 +1,44 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Modal, ModalProps, Table } from '@mantine/core';
import { IBid } from '../../system/type';
import { formatTime } from '../../utils';
export interface IShowHistoriesModalModalProps extends ModalProps {
data: IBid | null;
onUpdated?: () => void;
}
export default function ConfigModal({ data, onUpdated, ...props }: IShowHistoriesModalModalProps) {
const rows = data?.histories.map((element) => (
<Table.Tr key={element.id}>
<Table.Td>{element.id}</Table.Td>
<Table.Td>{element.price}</Table.Td>
<Table.Td>{formatTime(element.created_at, 'DD/MM/YYYY HH:MM')}</Table.Td>
</Table.Tr>
));
return (
<Modal {...props} size="xl" title={<span className="text-xl font-bold">Histories</span>} centered>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>ID</Table.Th>
<Table.Th>Price</Table.Th>
<Table.Th>Created at</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data && data.histories.length <= 0 ? (
<Table.Tr>
<Table.Td colSpan={3} className="text-center">
None
</Table.Td>
</Table.Tr>
) : (
rows
)}
</Table.Tbody>
</Table>
</Modal>
);
}

View File

@ -0,0 +1,2 @@
export { default as WorkingPage } from './working-page';
export { default as ShowImageModal } from './show-image-modal';

View File

@ -0,0 +1,19 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Image, Modal, ModalProps, ScrollArea } from '@mantine/core';
export default function ShowImageModal({ src, fallbackSrc, ...props }: ModalProps & { src: string; fallbackSrc: string }) {
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Image</span>}
centered
scrollAreaComponent={ScrollArea.Autosize}
>
<Image src={src} fallbackSrc={fallbackSrc} />
</Modal>
);
}

View File

@ -0,0 +1,67 @@
import { Box, Button, Image, Text } from '@mantine/core';
import { IBid, IWebBid } from '../../system/type';
import { Socket } from 'socket.io-client';
import { useEffect, useState } from 'react';
import ShowImageModal from './show-image-modal';
import { useDisclosure } from '@mantine/hooks';
import moment from 'moment';
export interface IWorkingPageProps {
data: (IBid | IWebBid) & { type: string };
socket: Socket;
}
export default function WorkingPage({ data, socket }: IWorkingPageProps) {
const fallbackSrc = 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRGh5WFH8TOIfRKxUrIgJZoDCs1yvQ4hIcppw&s';
const [opened, { open, close }] = useDisclosure(false);
const [imageSrc, setImageSrc] = useState(`${import.meta.env.VITE_BASE_URL}bids/status-working/${data.type.replace('_', '-').toLowerCase()}/${data.id}/working`);
const [lastUpdate, setLastUpdate] = useState(new Date());
useEffect(() => {
const updateImage = ({ type, id, filename }: { type: string; filename: string; id: IBid['id'] }) => {
if (type == data.type && id == data.id) {
setLastUpdate(new Date());
setImageSrc(`${import.meta.env.VITE_BASE_URL}bids/status-working/${type.replace('_', '-').toLowerCase()}/${id}/${filename}`);
}
};
socket.on('working', updateImage);
return () => {
socket.off('working', updateImage);
};
}, [socket, data.id, data.type]);
useEffect(() => {
console.log({ imageSrc });
}, [imageSrc]);
return (
<>
<Box className="rounded-md overflow-hidden relative shadow-lg">
<Image
radius="md"
h={300}
style={{
objectFit: 'cover',
}}
fallbackSrc={fallbackSrc}
src={imageSrc}
/>
<Box className="absolute bg-black/60 inset-0 flex items-center justify-center text-white font-bold flex-col gap-3 p-4 rounded-lg transition duration-300 hover:bg-opacity-70">
<Text className="text-lg uppercase tracking-wide">{data.type}</Text>
<Text className="text-sm italic opacity-80">{moment(lastUpdate).format('HH:mm:ss DD/MM/YYYY')}</Text>
<Button onClick={open} className="bg-white text-black px-4 py-2 rounded-md shadow-md hover:bg-gray-200 transition">
Show
</Button>
</Box>
</Box>
<ShowImageModal src={imageSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} />
</>
);
}

View File

@ -0,0 +1,5 @@
import { ToastContainer } from 'react-toastify';
export default function GlobalNotification() {
return <ToastContainer />;
}

View File

@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Box, Button } from '@mantine/core';
import { createRef, forwardRef, useState } from 'react';
import { Cropper, ReactCropperElement } from 'react-cropper';
export interface IImageEditorProps {
defaultSrc?: string;
onCrop?: (data: string) => void;
}
const ImageEditor = forwardRef<HTMLDivElement, IImageEditorProps>(({ onCrop, defaultSrc }, ref) => {
const [image, setImage] = useState(defaultSrc);
const cropperRef = createRef<ReactCropperElement>();
const onChange = (e: any) => {
e.preventDefault();
let files;
if (e.dataTransfer) {
files = e.dataTransfer.files;
} else if (e.target) {
files = e.target.files;
}
const reader = new FileReader();
reader.onload = () => {
setImage(reader.result as any);
};
reader.readAsDataURL(files[0]);
};
const getCropData = () => {
if (typeof cropperRef.current?.cropper !== 'undefined') {
if (!onCrop) return;
onCrop(cropperRef.current?.cropper.getCroppedCanvas().toDataURL());
}
};
return (
<Box className="flex flex-col gap-3" ref={ref}>
<Cropper
ref={cropperRef}
style={{ height: '400px', width: '100%' }}
zoomTo={0.5}
initialAspectRatio={1}
preview=".img-preview"
src={image}
viewMode={1}
minCropBoxHeight={10}
minCropBoxWidth={10}
background={false}
responsive={true}
autoCropArea={0}
checkOrientation={false}
guides={true}
/>
<input hidden onChange={onChange} type="file" id="edit-image-input" />
<Box className="flex items-center justify-between gap-4">
<Button fullWidth onClick={getCropData}>
Save
</Button>
</Box>
</Box>
);
});
export default ImageEditor;

View File

@ -0,0 +1,4 @@
export { default as Logo } from './logo';
export { default as UserMenu } from './user-menu';
export { default as GlobalNotification } from './global-notification';
export { default as ImageEditor } from './image-editor';

View File

@ -0,0 +1,13 @@
import { Link } from 'react-router-dom';
import Links from '../system/links';
import { Box } from '@mantine/core';
export default function Logo() {
return (
<Box className="flex items-center justify-center h-full max-w-fit">
<Link to={Links.DASHBOARD} className="exo-2 text-2xl font-bold">
Bid Admin
</Link>
</Box>
);
}

View File

@ -0,0 +1,110 @@
import { Avatar, Button, Menu, Modal, PasswordInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { changePassword, logout } from '../apis/auth';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import Links from '../system/links';
const schema = z
.object({
currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
export default function UserMenu() {
const [opened, { open, close }] = useDisclosure(false);
const { setConfirm } = useConfirmStore();
const navigate = useNavigate();
const form = useForm({
initialValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
validate: zodResolver(schema),
});
const handleSubmit = async (values: typeof form.values) => {
await handleChangePassword(values);
};
const handleLogout = async () => {
setConfirm({
title: "Are you wan't to logout?",
message: 'This account will logout !',
okButton: { value: 'Logout' },
handleOk: async () => {
const data = await logout();
if (data && data.data) {
navigate(Links.LOGIN);
}
},
});
};
const handleChangePassword = async (values: typeof form.values) => {
setConfirm({
title: "Are you wan't to update password",
message: 'This account will change password !',
okButton: { value: 'Sure' },
handleOk: async () => {
const data = await changePassword({
newPassword: values.newPassword,
password: values.currentPassword,
});
if (data && data.data) {
navigate(Links.LOGIN);
close();
}
},
});
};
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<Avatar color="blue" radius="xl" className="cursor-pointer">
<IconUser size={20} />
</Avatar>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
Change password
</Menu.Item>
<Menu.Divider />
<Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal opened={opened} onClose={close} title="Change password" centered>
<form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
<PasswordInput size="sm" label="Current password" {...form.getInputProps('currentPassword')} />
<PasswordInput size="sm" label="New password" {...form.getInputProps('newPassword')} />
<PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
<Button type="submit" fullWidth size="sm" mt="md">
Change
</Button>
</form>
</Modal>
</>
);
}

View File

@ -0,0 +1,2 @@
export { default as WebBidModal } from './web-bid-modal';
export { default as WebAccountModal } from './web-account-modal';

View File

@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, Modal, ModalProps, PasswordInput, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import { z } from 'zod';
import { updateBid } from '../../apis/bid';
import { createWebBid, updateWebBid } from '../../apis/web-bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IWebBid } from '../../system/type';
import { extractDomain } from '../../utils';
export interface IWebBidModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = z.object({
username: z.string().min(1, { message: 'Username is required' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters long' }),
});
export default function WebAccountModal({ data, onUpdated, ...props }: IWebBidModelProps) {
const form = useForm({
validate: zodResolver(schema),
});
const prevData = useRef<IWebBid | null>(data);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This account will be update`,
handleOk: async () => {
const result = await updateWebBid(values);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
const result = await updateWebBid(values);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Account</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
<TextInput className="col-span-2" size="sm" label="Username" {...form.getInputProps('username')} />
<PasswordInput className="col-span-2" size="sm" label="Password" {...form.getInputProps('password')} />
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
</Button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,110 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Button, Modal, ModalProps, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import _ from 'lodash';
import { useEffect, useRef } from 'react';
import { z } from 'zod';
import { updateBid } from '../../apis/bid';
import { createWebBid, updateWebBid } from '../../apis/web-bid';
import { useConfirmStore } from '../../lib/zustand/use-confirm';
import { IWebBid } from '../../system/type';
import { extractDomain } from '../../utils';
export interface IWebBidModelProps extends ModalProps {
data: IWebBid | null;
onUpdated?: () => void;
}
const schema = {
url: z.string({ message: 'Url is required' }).url('Invalid url format'),
};
export default function WebBidModal({ data, onUpdated, ...props }: IWebBidModelProps) {
const form = useForm({
validate: zodResolver(z.object(schema)),
});
const prevData = useRef<IWebBid | null>(data);
const { setConfirm } = useConfirmStore();
const handleSubmit = async (values: typeof form.values) => {
if (data) {
setConfirm({
title: 'Update ?',
message: `This web will be update`,
handleOk: async () => {
const result = await updateWebBid(values);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
},
okButton: {
color: 'blue',
value: 'Update',
},
});
} else {
const { url, origin_url } = values;
const result = await createWebBid({ url, origin_url } as IWebBid);
if (!result) return;
props.onClose();
if (onUpdated) {
onUpdated();
}
}
};
useEffect(() => {
form.reset();
if (!data) return;
form.setValues(data);
prevData.current = data;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
useEffect(() => {
if (!props.opened) {
form.reset();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.opened]);
useEffect(() => {
if (form.values?.url) {
form.setFieldValue('origin_url', extractDomain(form.values.url));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form.values]);
return (
<Modal
classNames={{
header: '!flex !item-center !justify-center w-full',
}}
{...props}
size={'xl'}
title={<span className="text-xl font-bold">Web</span>}
centered
>
<form onSubmit={form.onSubmit(handleSubmit)} className="grid grid-cols-2 gap-2.5">
<TextInput className="col-span-2" size="sm" label="Domain" {...form.getInputProps('origin_url')} />
<TextInput className="col-span-2" size="sm" label="Tracking url" {...form.getInputProps('url')} />
<Button disabled={_.isEqual(form.getValues(), prevData.current)} className="col-span-2" type="submit" fullWidth size="sm" mt="md">
{data ? 'Update' : 'Create'}
</Button>
</form>
</Modal>
);
}

View File

@ -0,0 +1,3 @@
export { default as usePermissions } from './use-permissions';
export { default as useCategories } from './use-categories';
export { default as useTags } from './use-tags';

View File

@ -0,0 +1,28 @@
import { useEffect, useState } from 'react';
import { getCategories } from '../apis/category';
import { ICategory } from '../system/type';
import { ComboboxData } from '@mantine/core';
export default function usePermissions() {
const [data, setData] = useState<ICategory[]>([]);
const handleGetData = async () => {
const result = await getCategories({ per_page: 100 });
if (!result || !result.data?.data) return;
setData(result.data.data);
};
useEffect(() => {
handleGetData();
}, []);
return {
data,
select: data.map((item) => {
return { label: item.name, value: String(item.id) };
}) as ComboboxData,
refresh: handleGetData,
};
}

View File

@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { IPermission } from '../system/type';
import { getPermissions } from '../apis/permission';
import { usePermissionStore } from '../lib/zustand/use-permission-store';
export default function usePermissions() {
const [data, setData] = useState<IPermission[]>([]);
const { setBasePermissions } = usePermissionStore();
const handleGetData = async () => {
const result = await getPermissions({ per_page: 100 });
if (!result || !result.data?.data) return;
setData(result.data.data);
setBasePermissions(result.data.data);
};
useEffect(() => {
handleGetData();
}, []);
return data;
}

View File

@ -0,0 +1,28 @@
import { ComboboxData } from '@mantine/core';
import { useEffect, useState } from 'react';
import { getTags } from '../apis/tag';
import { ITag } from '../system/type';
export default function useTags() {
const [data, setData] = useState<ITag[]>([]);
const handleGetData = async () => {
const result = await getTags({ per_page: 100 });
if (!result || !result.data?.data) return;
setData(result.data.data);
};
useEffect(() => {
handleGetData();
}, []);
return {
data,
select: data.map((item) => {
return { label: item.name, value: String(item.id) };
}) as ComboboxData,
refresh: handleGetData,
};
}

View File

@ -0,0 +1,23 @@
@import url('https://fonts.googleapis.com/css2?family=Exo+2:ital,wght@0,100..900;1,100..900&display=swap');
@import 'tailwindcss';
.exo-2 {
font-family: 'Exo 2', serif;
font-optical-sizing: auto;
}
@theme {
--font-display: 'Satoshi', 'sans-serif';
--breakpoint-3xl: 1920px;
--color-avocado-100: oklch(0.99 0 0);
--color-avocado-200: oklch(0.98 0.04 113.22);
--color-avocado-300: oklch(0.94 0.11 115.03);
--color-avocado-400: oklch(0.92 0.19 114.08);
--color-avocado-500: oklch(0.84 0.18 117.33);
--color-avocado-600: oklch(0.53 0.12 118.34);
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1);
/* ... */
--color-gray: rgba(0, 0, 0);
}

View File

@ -0,0 +1 @@
export { default as PrivateLayout } from './private-layout';

View File

@ -0,0 +1,57 @@
import { AppShell, Box, NavLink, ScrollArea } from '@mantine/core';
import { useCallback, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router';
import { me } from '../apis/auth';
import { Logo, UserMenu } from '../components';
import Links from '../system/links';
import { Link } from 'react-router-dom';
export default function PrivateLayout() {
const navigate = useNavigate();
const location = useLocation();
const checkAuth = useCallback(async () => {
const data = await me();
if (!data || !data.data) {
navigate(Links.LOGIN);
}
}, [navigate]);
useEffect(() => {
// checkAuth();
}, [checkAuth]);
return (
<AppShell header={{ height: 60 }} navbar={{ width: 300, breakpoint: 'sm' }} padding="md">
<AppShell.Header>
<Box className="flex items-center justify-between h-full px-4">
<Logo />
<UserMenu />
</Box>
</AppShell.Header>
<AppShell.Navbar px={'md'} pb={'md'}>
<AppShell.Section grow my="md" component={ScrollArea}>
<div className="w-full h-full flex flex-col gap-3">
{Links.MENUS.map((menu, index) => (
<NavLink
component={Link}
className="rounded-sm"
key={menu.path + index}
to={menu.path}
label={menu.title}
active={location.pathname === menu.path}
leftSection={<menu.icon size={16} stroke={1.5} />}
/>
))}
</div>
</AppShell.Section>
<AppShell.Section className="text-xs text-center">{new Date().getFullYear()}</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@ -0,0 +1,35 @@
import ax from 'axios';
import { HttpStatusCode } from 'axios';
import { toast } from 'react-toastify';
export interface IResponse<R> {
message: string;
status_code: HttpStatusCode;
data: R;
timestamp: string;
}
export interface IResponsePagination<T> extends IResponse<T> {
current_page: number;
from: number;
to: number;
last_page: number;
per_page: number;
total: number;
}
const axios = ax.create({
baseURL: import.meta.env.VITE_BASE_URL,
});
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 403) {
toast.error('Access denied');
}
return Promise.reject(error);
},
);
export default axios;

View File

@ -0,0 +1,222 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Box, Button, ComboboxItem, Dialog, Select, SelectProps, Text, TextInput, TextInputProps } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconSearch, IconX } from '@tabler/icons-react';
import { ReactNode, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { IActionData, ITableFilter, TRefTableActionFn } from './type';
import { searchKey } from './ultils';
export interface ITableActionsProps<R extends Record<string, string | number>> {
actions?: IActionData[];
chooses?: R[];
showSearch?: boolean;
loading?: boolean;
showAction?: boolean;
initFilter?: ITableFilter<R>[];
searchOptions?: {
props?: TextInputProps;
render?: () => ReactNode;
};
refAction?: TRefTableActionFn;
selectProps?: SelectProps;
onSearch?: (data: ITableFilter<R>[]) => void;
renderComfirm?: (data: IActionData) => ReactNode;
onCloseComfirm?: () => void;
}
export default function TableActions<R extends Record<string, string | number>>({
showSearch = true,
showAction = true,
loading,
searchOptions,
initFilter,
selectProps,
actions,
chooses,
refAction,
onSearch,
renderComfirm,
onCloseComfirm,
}: ITableActionsProps<R>) {
const [opened, { toggle, close }] = useDisclosure(false);
const [action, setAction] = useState<IActionData | null>(null);
const [isLoading, setIsLoading] = useState(loading || false);
const [selectValue, setSelectValue] = useState<string | null>(null);
const form = useForm<{ [searchKey]: string }>({
initialValues: {
[searchKey]: '',
},
});
const handleSubmit = (data: { [searchKey]: string }) => {
const filter = { type: data[searchKey].trim(), key: searchKey } as ITableFilter<R>;
if (onSearch) {
onSearch(data[searchKey]?.length ? [filter] : []);
}
form.reset();
};
const handleClear = () => {
form.reset();
if (onSearch) {
onSearch([]);
}
};
const handleChangeAction = (value: string | null, _option: ComboboxItem) => {
setSelectValue(value);
if (!actions) return;
const action = actions.find((action) => String(action.key) === value);
if (!action) return;
setAction(action);
if (action.comfirmAction) {
toggle();
} else {
handleCallBack(action.callback);
}
};
const handleCallBack = useCallback(
async (callback: (chooses: R[]) => void) => {
if (callback.constructor.name === 'AsyncFunction') {
try {
setIsLoading(true);
await callback((chooses || []) as R[]);
} finally {
setIsLoading(false);
}
} else {
callback((chooses || []) as R[]);
}
handleClose();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[chooses],
);
const actionDataMemo = useMemo(() => {
const newActions = actions?.reduce((prev, cur) => {
prev.push({ value: String(cur.key), label: cur.title, disabled: cur?.disabled ? cur.disabled(chooses || []) : false });
return prev;
}, [] as { value: string; label: string; disabled: boolean }[]);
return newActions;
}, [actions, chooses]);
const handleClose = () => {
close();
setAction(null);
setSelectValue(null);
if (onCloseComfirm) {
onCloseComfirm();
}
};
const handleClearAction = () => {
setAction(null);
setSelectValue(null);
};
const comfirmViewMemo = useMemo(() => {
if (!action || !action.comfirmAction) return;
return renderComfirm ? (
renderComfirm(action)
) : (
<Dialog opened={opened} withCloseButton onClose={handleClose} size="lg" radius="md">
<Text size="sm" mb="xs" fw={500}>
{action?.comfirmOption && action?.comfirmOption(action)?.title ? action.comfirmOption(action).title : 'Are you sure to execute this action'}
</Text>
<div className="flex items-center justify-end w-full gap-3">
<Button size="xs" disabled={isLoading} onClick={() => handleCallBack(action.callback)}>
Ok
</Button>
<Button size="xs" disabled={isLoading} color="red" onClick={handleClose}>
Close
</Button>
</div>
</Dialog>
);
}, [action, opened, close, renderComfirm]);
useEffect(() => {
if (!initFilter) return;
const params = initFilter.reduce((prev, cur) => {
if (cur.key === searchKey) {
prev[cur.key] = cur.type;
}
return prev;
}, {} as Record<string, string | number>);
form.setValues(params);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initFilter]);
useEffect(() => {
setIsLoading(!!loading);
}, [loading]);
useImperativeHandle(
refAction,
() => {
return {
setAction,
clearAction: handleClearAction,
};
},
[],
);
return (
<Box className="flex justify-between items-center">
{showSearch && searchOptions?.render ? (
searchOptions.render()
) : (
<form onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
{...form.getInputProps(searchKey)}
className="min-w-[260px]"
leftSection={<IconSearch size={'14px'} />}
rightSection={
form.getValues()[searchKey].length ? <IconX onClick={handleClear} className="cursor-pointer hover:text-red-400 select-none" size={'14px'} /> : undefined
}
placeholder="Search by keyword"
size="xs"
label={'Search'}
{...searchOptions?.props}
/>
</form>
)}
{showAction && (
<Select
size="xs"
value={selectValue}
onChange={handleChangeAction}
label="Actions"
placeholder="Pick value"
defaultChecked={false}
data={actionDataMemo}
{...selectProps}
/>
)}
{comfirmViewMemo}
</Box>
);
}

View File

@ -0,0 +1,18 @@
import { cn } from '../../utils';
export interface IActiveColumnProps {
active: boolean;
}
export default function ActiveColumn({ active }: IActiveColumnProps) {
return (
<div className="flex items-center justify-center">
<span
className={cn('w-3 aspect-square rounded-full', {
['bg-red-500']: !active,
['bg-green-500']: active,
})}
></span>
</div>
);
}

View File

@ -0,0 +1,344 @@
'use client';
import { Box, Button, MantineSize, MantineStyleProp, NumberInput, Pagination, Select, TextInput } from '@mantine/core';
import { DateInput, DateTimePicker } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { IconFilterExclamation, IconFilterSearch } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { IColumn, IDataFilter, IFilterItemProps, IOptions, ITableFilter } from './type';
import { defaultKeyPage, defaultKeyPerpage, defaultPerpageValue, defaultPrefixShort, removeFalsy } from './ultils';
export interface IFilterProps<R extends Record<string, string | number>> {
columns: IColumn<R>[];
loading?: boolean;
initFillter?: ITableFilter<R>[];
optionsTable?: IOptions<R>;
persistFilter?: IDataFilter[];
onSumit?: (filter: ITableFilter<R>[], options?: IDataFilter[]) => void;
}
export default function Filter<R extends Record<string, string | number>>({
columns,
initFillter,
loading,
perpage = { show: true },
pagination = { show: true },
optionsTable,
persistFilter,
onSumit,
...props
}: IFilterProps<R> & IFilterItemProps<R>) {
const defaultPerpage = optionsTable?.perPage ? optionsTable.perPage : defaultPerpageValue;
const defaultPage = optionsTable?.currentPage ? optionsTable.currentPage : 1;
// Initialize form with empty strings to maintain controlled state
const form = useForm<Record<string, string | number>>({
initialValues: {
...columns.reduce((acc, column) => {
acc[column.key] = '';
return acc;
}, {} as Record<string, string | number>),
...{
[perpage?.key || defaultKeyPerpage]: defaultPerpage,
},
...{
[pagination?.key || defaultKeyPage]: defaultPage,
},
},
});
const [open, setOpen] = useState(false);
// const [filter, setFilter] = useState<ITableFilter<R>[]>(initFillter ?? []);
const handleSubmitFilter = form.onSubmit((data) => {
const validData = removeFalsy(data);
const tableFilterData = Object.keys(validData).map((item) => {
return {
key: item,
type: item === (pagination.key || defaultKeyPage) ? 1 : validData[item],
} as ITableFilter<R>;
});
// setFilter(tableFilterData);
// form.setValues({
// [pagination?.key || defaultKeyPage]: 1,
// });
if (onSumit) onSumit(tableFilterData);
});
const handleSubmitOption = form.onSubmit((data) => {
const validData = removeFalsy(data);
const tableFilterData = Object.keys(validData).map((item) => {
return {
key: item,
type: validData[item],
} as ITableFilter<R>;
});
// setFilter(tableFilterData);
if (onSumit) onSumit(tableFilterData);
});
const handleClear = () => {
const validData = removeFalsy(form.getValues());
const params: { [key: string]: string | number } = {};
const tableFilterData = Object.keys(validData)
.filter((i) => ingorKeys.includes(i))
.map((item) => {
params[item] = validData[item];
return {
key: item,
type: validData[item],
} as ITableFilter<R>;
});
if (persistFilter && persistFilter?.length) {
persistFilter.reduce((prev, cur) => {
prev[cur.key] = cur.type;
return prev;
}, params);
}
form.reset();
form.setValues(params);
// setFilter(tableFilterData);
if (onSumit) onSumit(tableFilterData);
};
const ingorKeys = useMemo(() => {
const persistKeys = persistFilter && persistFilter.length ? persistFilter.map((i) => i.key) : [];
return [perpage?.key || defaultKeyPerpage, pagination?.key || defaultKeyPage, ...persistKeys];
}, [perpage?.key, pagination.key, persistFilter]);
useEffect(() => {
if (!initFillter) return;
const params = initFillter.reduce((prev, cur) => {
prev[cur.key] = cur.type;
return prev;
}, {} as Record<string, string | number>);
form.setValues(params);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initFillter, optionsTable]);
useEffect(() => {
if (!optionsTable?.perPage) return;
form.setFieldValue(perpage?.key || defaultKeyPerpage, String(optionsTable.perPage));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [optionsTable?.perPage]);
const defaultStyleInput = {
size: 'xs' as MantineSize,
style: { display: 'flex', alignItems: 'start', flexDirection: 'column', gap: '4px' } as MantineStyleProp,
styles: {
input: {
minWidth: '240px',
},
},
};
const showCautionFillterIcon = useMemo(() => {
return (
Object.keys(removeFalsy(form.getValues())).filter((i) => {
return !ingorKeys.includes(i) && !i.includes(optionsTable?.prefixShort || defaultPrefixShort);
}).length > 0
);
}, [form, ingorKeys, optionsTable?.prefixShort]);
const paginationMemo = useMemo(() => {
return (
<Pagination
value={Number(form.getValues()[pagination.key || defaultKeyPage]) || 1}
total={optionsTable?.lastPage || 1}
onChange={(value) => {
form.setFieldValue(pagination.key || defaultKeyPage, value);
handleSubmitOption();
}}
size={'sm'}
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pagination, optionsTable]);
return (
<Box>
{open && (
<form onSubmit={handleSubmitFilter} className="border mb-5 p-10 rounded-md ">
<Box className="flex rounded-md gap-4 flex-wrap">
{columns.map((column) => {
if (column.typeFilter === 'none') return <></>;
if (typeof column.typeFilter === 'object') {
switch (column.typeFilter.type) {
case 'select':
return (
<Select
key={column.key}
{...form.getInputProps(column.key)}
label={column.title}
maxDropdownHeight={200}
searchable
data={column.typeFilter.data}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
checkIconPosition="right"
{...props.select}
/>
);
default:
return (
<Select
key={column.key}
{...form.getInputProps(column.key)}
label={column.title}
maxDropdownHeight={200}
searchable
data={column.typeFilter.data}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.select}
/>
);
}
} else {
switch (column.typeFilter) {
case 'text':
return (
<TextInput
key={column.key}
{...form.getInputProps(column.key)}
label={column.title}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.text}
/>
);
case 'number':
return (
<NumberInput
key={column.key}
{...form.getInputProps(column.key)}
label={column.title}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.number}
/>
);
case 'date':
return (
<DateInput
key={column.key}
valueFormat="DD/MM/YYYY HH:mm:ss"
{...form.getInputProps(column.key)}
value={form.getValues()[column.key] ? new Date(Number(form.getValues()[column.key])) : undefined}
onChange={(value) => {
form.setFieldValue(column.key as string, value?.getTime() || '');
}}
label={column.title}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.date}
/>
);
case 'datetime':
return (
<DateTimePicker
key={column.key}
{...form.getInputProps(column.key)}
value={form.getValues()[column.key] ? new Date(Number(form.getValues()[column.key])) : undefined}
onChange={(value) => {
form.setFieldValue(column.key as string, value?.getTime() || '');
}}
label={column.title}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.datatime}
/>
);
default:
return (
<TextInput
key={column.key}
{...form.getInputProps(column.key)}
label={column.title}
size={defaultStyleInput.size}
style={defaultStyleInput.style}
styles={defaultStyleInput.styles}
{...props.text}
/>
);
}
}
})}
</Box>
<Box className="flex items-center justify-end gap-2 mt-5">
<Button size="xs" onClick={handleClear}>
Clear
</Button>
<Button disabled={loading} type="submit" size="xs">
Filter
</Button>
</Box>
</form>
)}
<div className="w-full flex items-end justify-between">
<div onClick={() => setOpen((prev) => !prev)} className="w-fit cursor-pointer flex items-start justify-end flex-col">
{showCautionFillterIcon ? <IconFilterExclamation color="red" size={20} /> : <IconFilterSearch size={20} />}
<span className="text-sm font-medium text-[#81838a] mt-2 italic">
{(optionsTable && props.description && props.description(optionsTable)) ||
`Show ${optionsTable?.from || 0} to ${optionsTable?.to || 0} of ${optionsTable?.total || 0} entries`}
</span>
</div>
<div className="flex items-end gap-4">
{paginationMemo}
{perpage?.show && (
<Select
{...form.getInputProps(perpage?.key || defaultKeyPerpage)}
onChange={(value) => {
form.setFieldValue(perpage?.key || defaultKeyPerpage, value || defaultPerpage);
handleSubmitOption();
}}
size="xs"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'start',
gap: '4px',
}}
label="Per page"
data={['5', '10', '15', '20']}
{...perpage.perpageProps}
/>
)}
</div>
</div>
</Box>
);
}

View File

@ -0,0 +1,776 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faArrowDownAZ, faArrowsUpDown, faArrowUpAZ } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Box, Checkbox, MantineStyleProp, Table as MTable, TableProps as MTableProps, Tooltip } from '@mantine/core';
import { AxiosError, AxiosResponse } from 'axios';
import DOMPurify from 'dompurify';
import React, { ChangeEvent, CSSProperties, ReactNode, useCallback, useEffect, useImperativeHandle, useState } from 'react';
import TableActions, { ITableActionsProps } from './action';
import Filter from './filter';
import { v4 as uuid } from 'uuid';
import { IChooseOptions, IColumn, IColumnStyle, IDataFilter, IFilterItemProps, IOptions, ITableFilter, ITableShort, TableChildProps, TKeyPagination, TRefTableFn } from './type';
import { defaultPathToData, defaultPrefixShort, defaultStyleHightlight, flowShort, getParamsData as getParamsFromURL, searchKey } from './ultils';
export interface TableProps<R extends Record<string, string | number>> extends MTableProps {
columns: IColumn<R>[];
rows?: R[];
rowKey: Extract<keyof R, string>;
tableChildProps?: TableChildProps;
iconUp?: ReactNode;
iconNormal?: ReactNode;
iconDown?: ReactNode;
options?: IOptions<R>;
disableAutoShort?: boolean;
emptyDataTemplate?: ReactNode;
loadingTemplate?: ReactNode;
persistFilter?: IDataFilter[];
showLoading?: boolean;
addToHistoryBrowserWhenFillter?: boolean;
showFilter?: boolean;
refTableFn?: TRefTableFn<R>;
renderPramsOnUrl?: boolean;
filterProps?: IFilterItemProps<R>;
highlightResult?: {
show?: boolean;
style?: React.CSSProperties;
};
styleDefaultHead?: CSSProperties;
actions?: {
title?: string | ReactNode;
body: (row: R) => ReactNode;
};
showChooses?: boolean;
showActions?: boolean;
chooseOptions?: IChooseOptions<R>;
actionsOptions?: ITableActionsProps<R>;
filterTemplate?: () => ReactNode;
onSort?: (short: ITableShort<R> | null) => void;
onFilter?: (filter: ITableFilter<R>[]) => void;
onFetchError?: (error: AxiosError) => void;
onAfterFetch?: () => void;
onParams?: (params: Record<string, string | number>) => void;
onFetched?: (response: AxiosResponse<R[]>) => void;
onChooses?: (chooses: R[]) => void;
onSearch?: (value: ITableFilter<R>[]) => void;
}
const TableIcon = ({
children,
label,
icon,
disabled = false,
onClick,
}: {
children?: ReactNode;
icon?: IconProp;
label: string;
disabled?: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
}) => {
return (
<Tooltip className="bg-gray-600" style={{ textTransform: 'capitalize' }} label={label}>
<Box onClick={!disabled ? onClick : undefined} className="font-bold p-1 rounded-full hover:bg-gray-100 w-6 h-6 flex items-center justify-center select-none">
{children ? (
<Box style={{ fontSize: '12px', color: 'gray' }}>{children}</Box>
) : icon ? (
<FontAwesomeIcon style={{ fontSize: '12px', color: disabled ? 'transparent' : 'gray' }} icon={icon} />
) : (
<span></span>
)}
</Box>
</Tooltip>
);
};
const Table = <R extends Record<string, any>>({
columns,
rows = [],
rowKey,
tableChildProps = {},
options,
disableAutoShort = false,
emptyDataTemplate,
loadingTemplate,
showLoading,
addToHistoryBrowserWhenFillter,
showFilter = true,
refTableFn,
filterProps,
persistFilter,
actions,
styleDefaultHead,
renderPramsOnUrl = true,
showChooses = true,
chooseOptions,
showActions = true,
highlightResult = { show: true, style: defaultStyleHightlight },
actionsOptions,
onSort,
onAfterFetch,
onFetched,
onFilter,
onParams,
onFetchError,
onChooses,
onSearch,
...props
}: TableProps<R>) => {
const paramsUrl = new URLSearchParams(window.location.search);
const { thead, trhead, trbody, tbody, th, td } = tableChildProps;
const [short, setShort] = useState<ITableShort<R> | null>(null);
const [filter, setFilter] = useState<ITableFilter<R>[]>([]);
const [rowsData, setRowsData] = useState([...rows]);
const [loading, setLoading] = useState(showLoading || false);
const [optionPagiantion, setOptionPagiantion] = useState(options);
const [checkedAll, setCheckedAll] = useState(false);
const [chooses, setChooses] = useState<R[]>([]);
const renderStyleHead = useCallback(
(styleHead: IColumnStyle) => {
switch (styleHead?.type) {
case 'single':
if (!styleHead?.style) throw new Error('With type is "single" STYLE is requie');
return styleHead.style;
case 'extents':
return {
...th?.style,
...styleHead.style,
};
case 'parent':
return {
...th?.style,
};
default:
if (!styleHead?.style) throw new Error('With type is "single" STYLE is requie');
return styleHead.style;
}
},
[th],
);
function getValueFromPath(obj: AxiosResponse<R[]>, path: string): any {
// Tách chuỗi path thành các phần bằng dấu '.'
const keys = path.split('.');
// Duyệt qua các phần của path để truy xuất giá trị từ đối tượng
return keys.reduce<any>((acc, key) => {
// Xử lý chỉ số mảng (nếu có)
const [arrayKey, index] = key.split('[');
const cleanKey = arrayKey.trim();
if (index !== undefined) {
const cleanIndex = index.replace(']', '').trim();
return acc && Array.isArray(acc[cleanKey]) ? acc[cleanKey][parseInt(cleanIndex, 10)] : undefined;
}
return acc && acc[cleanKey] !== undefined ? acc[cleanKey] : undefined;
}, obj);
}
const getOptionsFromResponse = useCallback(
(response: AxiosResponse<R[]>) => {
if (!options) return;
const values = getValueFromPath(response, options.pathToOption || defaultPathToData);
const optionsKeys: TKeyPagination = options.keyOptions || {
to: 'to',
from: 'from',
total: 'total',
lastPage: 'lastPage',
perPage: 'perPage',
};
const resutl = Object.keys(values)
.filter((item) => Object.keys(optionsKeys).includes(item))
.reduce((prev, cur) => {
prev[optionsKeys[cur]] = values[cur];
return prev;
}, {} as { [key: string]: string | number });
return resutl;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options],
);
const fetchData = useCallback(
async (shortData: ITableShort<R> | null, filter?: ITableFilter<R>[] | null, search?: { [searchKey]: string } | null) => {
let params = {};
if (renderPramsOnUrl) {
params = renderFilter(shortData, filter, search).params;
}
if (onParams) onParams(params);
if (!options?.query) return;
try {
if (onAfterFetch) onAfterFetch();
setLoading(true);
const response = await (options && options.query && options.query(params));
if (!response) {
console.warn('Response is error. Please check');
return;
}
const data = getValueFromPath(response, options?.pathToData || defaultPathToData);
const optionPased = getOptionsFromResponse(response);
if (options) {
setOptionPagiantion({ ...options, ...optionPased });
} else {
setOptionPagiantion({ ...(optionPased as unknown as IOptions<R>) });
}
if (Array.isArray(data)) {
requestIdleCallback(
() => {
setRowsData(data);
},
{ timeout: 1000 },
);
} else {
console.warn(`Expect to receive an array but the data is an ${typeof data}`);
}
if (onFetched) onFetched(response);
} catch (error) {
if (onFetchError) onFetchError(error as AxiosError);
} finally {
setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[options?.query, filter, getOptionsFromResponse],
);
const fetchWithShort = useCallback(
(shortData: ITableShort<R> | null) => {
setShort(shortData);
fetchData(shortData);
},
[fetchData],
);
const renderShortIcon: (col: IColumn<R>) => ReactNode = useCallback(
(col: IColumn<R>) => {
const handleShort = () => {
if (loading) return;
if (!short) {
fetchWithShort({ key: col.key, type: flowShort[0] });
if (onSort) onSort({ key: col.key, type: flowShort[0] });
return;
}
if (short.type === 'desc') {
fetchWithShort({ key: col.key, type: flowShort[1] });
if (onSort) onSort({ key: col.key, type: flowShort[1] });
return;
}
if (short.type === 'asc') {
fetchWithShort(null);
if (onSort) onSort(null);
return;
}
};
const DefaultIcon = ({ disabled = false }: { disabled?: boolean }) => {
return props?.iconNormal ? (
<Box onClick={handleShort}>{props.iconNormal}</Box>
) : (
<TableIcon disabled={disabled} onClick={handleShort} label="desc" icon={faArrowsUpDown} />
);
};
if (!short) return <DefaultIcon />;
if (short && col.key !== short.key) return <DefaultIcon disabled={true} />;
switch (short.type) {
case 'asc':
return props?.iconUp ? <Box onClick={handleShort}>{props.iconUp}</Box> : <TableIcon onClick={handleShort} label={'clear'} icon={faArrowUpAZ} />;
case 'desc':
return props?.iconDown ? <Box onClick={handleShort}>{props.iconDown}</Box> : <TableIcon onClick={handleShort} label={'asc'} icon={faArrowDownAZ} />;
case 'clear':
return <DefaultIcon />;
default:
return <DefaultIcon />;
}
},
[short, loading, onSort, fetchWithShort, props.iconNormal, props.iconUp, props.iconDown],
);
function sortData<R extends Record<string, string | number>>(data: R[], options: ITableShort<R> | null): R[] {
const originalData = [...data];
if (!options) return originalData;
const { type, key } = options;
if (type === 'asc') {
return data.sort((a, b) => {
if (typeof a[key] === 'number' && typeof b[key] === 'number') {
return a[key] - b[key]; // Sắp xếp số
} else {
return String(a[key]).localeCompare(String(b[key])); // Sắp xếp chuỗi
}
});
} else if (type === 'desc') {
return data.sort((a, b) => {
if (typeof a[key] === 'number' && typeof b[key] === 'number') {
return b[key] - a[key]; // Sắp xếp số
} else {
return String(b[key]).localeCompare(String(a[key])); // Sắp xếp chuỗi
}
});
} else if (type === 'clear') {
return originalData;
} else {
throw new Error("Loại sắp xếp không hợp lệ. Chỉ chấp nhận 'asc', 'desc' hoặc 'clear'.");
}
}
const renderParamsUrl = (dataFilter: IDataFilter[]) => {
const pathname = window.location.pathname;
if (dataFilter.length <= 0) {
window.history.replaceState({}, '', pathname);
if (addToHistoryBrowserWhenFillter) {
window.history.pushState({}, '', pathname);
}
return;
}
dataFilter.forEach((param) => {
paramsUrl.set(param.key, String(param.type));
});
paramsUrl.forEach((_, key) => {
const item = dataFilter.find((item) => key == item.key);
if (!item) paramsUrl.delete(key);
});
window.history.replaceState({}, '', `${pathname}?${paramsUrl}`);
if (addToHistoryBrowserWhenFillter) {
window.history.pushState({}, '', `${pathname}?${paramsUrl}`);
}
};
const renderFilter = useCallback(
(short: ITableShort<R> | ITableFilter<R> | null, filterData?: ITableFilter<R>[] | null, search?: { [searchKey]: string } | null) => {
let dataFilter: IDataFilter[] = [...(filterData ? (filterData as IDataFilter[]) : (filter as IDataFilter[]))];
const params: { [key: string]: string } = {};
if (short && Object.keys(short).length > 0) {
const shortItem = dataFilter.find((item) => item.key.includes(options?.prefixShort || defaultPrefixShort));
if (shortItem) {
dataFilter = dataFilter.filter((item) => !item.key.includes(options?.prefixShort || defaultPrefixShort));
}
dataFilter.push({ key: `${options?.prefixShort || defaultPrefixShort}${short.key}`, type: String(short.type) });
} else {
dataFilter = dataFilter.filter((item) => !item.key.includes(options?.prefixShort || defaultPrefixShort));
}
dataFilter.forEach((filter) => {
params[filter.key] = String(filter.type);
});
if (search) {
params[searchKey] = search[searchKey];
}
renderParamsUrl(dataFilter);
return {
url: paramsUrl.toString(),
params,
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[filter, options?.prefixShort, paramsUrl],
);
const styleToString = (style: React.CSSProperties) => {
return Object.keys(style).reduce(
(acc, key) =>
((acc +
key
.split(/(?=[A-Z])/)
.join('-')
.toLowerCase() +
':' +
style[key as keyof React.CSSProperties]) as string) + ';',
'',
);
};
const renderRow = useCallback(
(row: R, col: IColumn<R>) => {
if (!highlightResult.show) {
if (col.renderRow) return col.renderRow(row);
return row[col.key];
}
const item = filter.find((key) => key.key === col.key);
if (item) {
if (col.renderRow) return col.renderRow(row, item as IDataFilter);
return (
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(
String(row[col.key]).replace(
String(item.type),
`<span style="${styleToString(highlightResult.style || defaultStyleHightlight)}">${item.type}</span>`,
),
),
}}
></div>
);
}
return col.renderRow ? col.renderRow(row) : row[col.key];
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[filter],
);
const handleSetChoosesData = useCallback((dataChooses: R[]) => {
const uniqueChooses = Array.from(new Map(dataChooses.map((item) => [JSON.stringify(item), item])).values());
setChooses(uniqueChooses);
if (onChooses) {
onChooses(uniqueChooses);
}
}, []);
const handleChooses = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked;
setCheckedAll(checked);
const dataChooses = checked ? [...rowsData] : [];
handleSetChoosesData(dataChooses);
},
[rowsData, handleSetChoosesData],
);
const handleChooseSingle = useCallback(
(e: ChangeEvent<HTMLInputElement>, item: R) => {
const checked = e.target.checked;
let newDataChooses: R[] = [];
if (checked) {
newDataChooses = [...chooses, item];
} else {
newDataChooses = chooses.filter((i) => JSON.stringify(i) !== JSON.stringify(item));
}
handleSetChoosesData(newDataChooses);
},
[rowsData, chooses, handleSetChoosesData],
);
// use effect space
useEffect(() => {
if (options?.query || disableAutoShort) return;
const data = sortData<R>(rowsData, short);
setRowsData([...data]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options?.query, short]);
useEffect(() => {
if (rows.length <= 0) {
setRowsData([]);
return;
}
setRowsData([...rows]);
setChooses([]);
}, [rows]);
useEffect(() => {
setLoading(showLoading || false);
}, [showLoading]);
useEffect(() => {
if (!options) return;
setOptionPagiantion(options);
}, [options]);
useEffect(() => {
if (!persistFilter?.length) return;
setFilter(persistFilter as ITableFilter<R>[]);
}, [persistFilter]);
// useImperativeHandle space
useImperativeHandle(
refTableFn,
() => {
return {
currentFilter: () => filter,
setFilter,
getLoading: () => loading,
fetchData: (short, filter) => fetchData(short || null, filter),
};
},
[filter, loading, fetchData],
);
useEffect(() => {
const { filterParamsData, shortParamsData } = getParamsFromURL({ columns, prefixShort: options?.prefixShort || defaultPrefixShort });
if (filterParamsData && filterParamsData.length > 0) {
setFilter(filterParamsData as ITableFilter<R>[]);
}
if (shortParamsData && Object.keys(shortParamsData).length > 0) {
setShort(shortParamsData as ITableShort<R>);
}
fetchData((shortParamsData as ITableShort<R>) || null, filterParamsData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options]);
// useEffect(() => {
// console.log('filter', filter);
// }, [filter]);
useEffect(() => {
if (chooses.length < rowsData.length) {
setCheckedAll(false);
} else {
setCheckedAll(true);
}
}, [chooses, rowsData]);
return (
<>
<div className="mb-5 flex flex-col gap-3">
{showFilter && (
<Filter
persistFilter={persistFilter}
loading={loading}
initFillter={[...filter.filter((item) => item.key !== searchKey)]}
onSumit={(data) => {
let newFilter: ITableFilter<R>[] = [];
const search = filter.filter((item) => item.key === searchKey);
if (search?.length) {
newFilter = [...data, ...search];
} else {
newFilter = [...data];
}
setFilter(newFilter);
fetchData(short, newFilter);
if (onFilter) {
onFilter(newFilter);
}
}}
columns={columns}
optionsTable={optionPagiantion}
{...filterProps}
/>
)}
{showActions && (
<TableActions
chooses={chooses}
initFilter={[...filter.filter((item) => item.key === searchKey)]}
onSearch={(data) => {
setFilter(data);
fetchData(short, data);
if (onSearch) {
onSearch(data);
}
}}
{...actionsOptions}
/>
)}
</div>
<MTable {...props}>
<MTable.Thead {...thead}>
<MTable.Tr {...trhead}>
{showChooses && (
<MTable.Th {...th}>
{chooseOptions?.renderHead ? (
chooseOptions.renderHead(chooses, handleChooses)
) : (
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Checkbox
classNames={{
input: 'cursor-pointer',
}}
checked={checkedAll}
onChange={handleChooses}
size="xs"
/>
</Box>
)}
</MTable.Th>
)}
{columns.map((column) => (
<MTable.Th {...th} style={{ ...(column.style ? (renderStyleHead(column.style) as MantineStyleProp) : th?.style) }} key={`${column.key}-${uuid()}`}>
<Box
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
...(styleDefaultHead || {}),
...column.styleDefaultHead,
}}
>
{column.renderColumn ? column.renderColumn(column) : column.title}
{renderShortIcon(column)}
</Box>
</MTable.Th>
))}
{actions && (
<MTable.Th {...th} key={'__action_head_' + columns.length + 1}>
{actions.title ? actions.title : 'Action'}
</MTable.Th>
)}
</MTable.Tr>
</MTable.Thead>
<MTable.Tbody {...tbody} className="relative">
{rowsData.length > 0 &&
rowsData.map((row, index) => (
<MTable.Tr {...trbody} key={row[rowKey]}>
{showChooses && (
<MTable.Td key={'__choose_' + index} {...td} {...chooseOptions?.defaultBodyProps}>
{chooseOptions?.renderBody ? (
chooseOptions.renderBody(chooses, row, (e) => handleChooseSingle(e, row))
) : (
<Box style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Checkbox
classNames={{
input: 'cursor-pointer',
}}
checked={chooses.some((choose) => JSON.stringify(choose) === JSON.stringify(row))}
onChange={(e) => handleChooseSingle(e, row)}
size="xs"
/>
</Box>
)}
</MTable.Td>
)}
{columns.map((col) => (
<MTable.Td key={`${col.key}-${uuid()}`} {...td}>
{/* {col.renderRow ? col.renderRow(row) : row[col.key]} */}
{renderRow(row, col)}
</MTable.Td>
))}
{actions && (
<MTable.Td key={'__action_' + index} {...td}>
{actions.body(row)}
</MTable.Td>
)}
</MTable.Tr>
))}
{rowsData.length <= 0 &&
(emptyDataTemplate ? (
emptyDataTemplate
) : (
<MTable.Tr>
<MTable.Td className="h-10">
<div className="absolute border border-[#424242] border-t-0 w-full h-full top-0 left-0 flex items-center justify-center font-medium bg-[#242424]">
<span>Empty Data</span>
</div>
</MTable.Td>
</MTable.Tr>
))}
{showLoading &&
loading &&
(loadingTemplate ? (
loadingTemplate
) : (
<MTable.Tr>
<MTable.Td className="h-10">
<div className="absolute w-full h-full top-0 left-0 flex items-center justify-center font-medium bg-[rgba(0,0,0,.4)]">
<svg
aria-hidden="true"
className="inline w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-gray-600 dark:fill-gray-300"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</MTable.Td>
</MTable.Tr>
))}
</MTable.Tbody>
</MTable>
</>
);
};
export default Table;

View File

@ -0,0 +1,152 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
__InputStylesNames,
ComboboxData,
InputVariant,
MantineComponent,
MantineStyleProp,
NumberInputCssVariables,
NumberInputProps,
NumberInputStylesNames,
SelectProps,
SelectStylesNames,
TableTbodyProps,
TableTdProps,
TableTheadProps,
TableThProps,
TableTrProps,
TextInputProps,
} from '@mantine/core';
import { DateInputProps, DateInputStylesNames, DateTimePickerProps, DateTimePickerStylesNames } from '@mantine/dates';
import { AxiosResponse } from 'axios';
import React, { ChangeEventHandler, ReactNode } from 'react';
export type TShort = 'asc' | 'desc' | 'clear';
export type TTypeFilter = 'none' | 'text' | 'number' | 'date' | 'datetime' | { type: 'select'; data: string[] | ComboboxData };
export type TRefTableFn<R extends Record<string, any>> = React.MutableRefObject<{
setFilter?: (filter: ITableFilter<R>[]) => void;
getLoading?: () => boolean;
currentFilter?: () => ITableFilter<R>[];
fetchData?: (shortData?: ITableShort<R> | null, filter?: ITableFilter<R>[] | null) => Promise<void>;
}>;
export type TRefTableActionFn = React.MutableRefObject<{
setAction?: (data: IActionData) => void;
clearAction?: () => void;
}>;
export interface IColumnStyle {
type?: 'single' | 'parent' | 'extents';
style?: TableThProps['style'];
}
export type IColumn<R extends Record<string, any>> = {
key: Extract<keyof R, string>;
title: string;
renderRow?: (row: R, highlightData?: IDataFilter) => ReactNode;
renderColumn?: (col: Omit<IColumn<R>, 'renderRow' | 'renderColumn'>) => ReactNode;
style?: IColumnStyle;
styleDefaultHead?: MantineStyleProp;
typeFilter?: TTypeFilter;
};
export type TKeyPagination = { [key: string]: 'to' | 'from' | 'total' | 'lastPage' | 'perPage' };
export interface TableChildProps {
thead?: TableTheadProps;
trhead?: TableTrProps;
trbody?: TableTrProps;
tbody?: TableTbodyProps;
th?: TableThProps;
td?: TableTdProps;
}
export interface IOptions<R extends Record<string, string | number>> {
currentPage?: number;
from?: number | null;
lastPage?: number;
// path: string;
query?: (params: Record<string, string | number>) => Promise<AxiosResponse<R[], any>>;
perPage?: number;
to?: number | null;
total?: number;
prefixShort?: string;
pathToData?: string; // 'data.data' default 'data'
pathToOption?: string;
keyOptions?: TKeyPagination;
}
export interface IDataFilter {
type: string;
key: string;
}
export interface ITableShort<R extends Record<string, any>> {
type: TShort;
key: IColumn<R>['key'];
}
export interface ITableFilter<R extends Record<string, any>> {
type: string | number;
key: IColumn<R>['key'];
}
export interface IFilterItemProps<R extends Record<string, string | number>> {
select?: MantineComponent<{
props: SelectProps;
ref: HTMLInputElement;
stylesNames: SelectStylesNames;
variant: InputVariant;
}>;
text?: MantineComponent<{
props: TextInputProps;
ref: HTMLInputElement;
stylesNames: __InputStylesNames;
}>;
number?: MantineComponent<{
props: NumberInputProps;
ref: HTMLDivElement;
stylesNames: NumberInputStylesNames;
vars: NumberInputCssVariables;
variant: InputVariant;
}>;
date?: MantineComponent<{
props: DateInputProps;
ref: HTMLInputElement;
stylesNames: DateInputStylesNames;
variant: InputVariant;
}>;
datatime?: MantineComponent<{
props: DateTimePickerProps;
ref: HTMLButtonElement;
stylesNames: DateTimePickerStylesNames;
variant: InputVariant;
}>;
perpage?: {
show?: boolean;
perpageProps?: SelectProps;
key?: string;
};
pagination?: {
show?: boolean;
key?: string;
};
description?: (option: IOptions<R>) => ReactNode;
}
export interface IChooseOptions<R extends Record<string, any>> {
defaultHeadProps?: TableThProps;
defaultBodyProps?: TableTdProps;
renderHead?: (chooses: R[], handle: ChangeEventHandler<HTMLInputElement>) => ReactNode;
renderBody?: (chooses: R[], choose: R, handle: ChangeEventHandler<HTMLInputElement>) => ReactNode;
}
export interface IActionData {
key: string | number;
title: string;
disabled?: (chooses: any[]) => boolean;
callback: (chooses: any[]) => void;
comfirmAction?: boolean;
comfirmOption?: (data: Omit<IActionData, 'callback' | 'comfirmAction' | 'comfirmOption'>) => { title: string };
}

View File

@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { IColumn, IDataFilter, ITableFilter, ITableShort, TShort } from '../type';
export const defaultPrefixShort = 'order_by_';
export const defaultPathToData = 'data';
export const flowShort: TShort[] = ['desc', 'asc'];
export const defaultKeyPerpage = 'per_page';
export const defaultKeyPage = 'page';
export const searchKey = 'search_key';
export const defaultStyleHightlight = { color: 'red', backgroundColor: 'yellow' } as React.CSSProperties;
export const defaultPerpageValue = '10';
export const getParamsData = <R extends Record<string, string | number>>(options: { prefixShort?: string; columns: IColumn<R>[] }) => {
const paramsUrl = new URLSearchParams(window.location.search);
if (!paramsUrl.size) return {};
const prefixShort = options?.prefixShort || defaultPrefixShort;
const paramObject: { [key: string]: string | number } = {};
paramsUrl.forEach((value, key) => {
paramObject[key] = value;
});
const pramsKeys = Object.keys(paramObject);
if (pramsKeys.length <= 0)
return {
shortParamsData: {},
searchParamsData: {},
filterParamsData: [],
params: {},
};
const shortParamsData = pramsKeys
.filter((item) => item.includes(prefixShort) && options.columns.map((col) => col.key).includes(item.replace(prefixShort, '') as IColumn<R>['key']))
.map((i) => {
return { key: i.replace(prefixShort, ''), type: flowShort.includes(paramObject[i] as TShort) ? paramObject[i] : 'asc' } as ITableShort<R>;
});
const filterParamsData = pramsKeys
// .filter((item) => options.columns.map((col) => col.key).includes(item as IColumn<R>['key']))
.map((item) => ({ key: item, type: paramObject[item] } as ITableFilter<R>));
const shortObject = shortParamsData[0] ? { [`${prefixShort}${shortParamsData[0].key}`]: String(shortParamsData[0].type) } : {};
const pramsData: IDataFilter[] = [...(filterParamsData ? (filterParamsData as IDataFilter[]) : [])];
let params = convertToParams(pramsData as unknown as Record<string, string | number>[]);
if (shortParamsData.length) {
params = {
...params,
...shortObject,
};
}
return { shortParamsData: shortParamsData[0] || {}, filterParamsData, params };
};
export const convertToParams = (filter: Record<string, string | number>[]) => {
const params = filter.reduce((prev, cur) => {
prev[cur.key] = cur.type;
return prev;
}, {} as Record<string, string | number>);
return params;
};
export const removeFalsy = (data: { [key: string]: string | number }) => {
return Object.keys(data).reduce((prev, cur) => {
if (data[cur]) {
prev[cur] = data[cur];
}
return prev;
}, {} as { [key: string]: string | number });
};

View File

@ -0,0 +1,25 @@
import { ButtonProps } from '@mantine/core';
import { create } from 'zustand';
type ConfirmProps = {
title?: string;
message?: string;
okButton?: ButtonProps & { value: string };
handleOk?: () => void;
};
interface ConfirmState {
open: boolean;
props: ConfirmProps;
setConfirm: (props: ConfirmProps) => void;
toggle: () => void;
close: () => void;
}
export const useConfirmStore = create<ConfirmState>((set) => ({
open: false,
props: {},
setConfirm: (newProps: ConfirmProps) => set({ props: newProps, open: true }),
toggle: () => set((state) => ({ open: !state.open })),
close: () => set({ open: false, props: {} }),
}));

View File

@ -0,0 +1,34 @@
import { create } from 'zustand';
import _ from 'lodash';
import { IPermission } from '../../system/type';
interface ConfirmState {
basePermission: IPermission[];
permissions: IPermission[];
setPermission: (newPermission: IPermission) => void;
setPermissions: (newPermissions: IPermission[]) => void;
setBasePermissions: (newPermissions: IPermission[]) => void;
deletePermission: (permission: IPermission) => void;
}
export const usePermissionStore = create<ConfirmState>((set) => ({
basePermission: [],
permissions: [],
setPermission: (newPermission: IPermission) =>
set((state) => ({
permissions: _.uniqBy([...state.permissions, newPermission], 'id'),
})),
setPermissions: (newPermissions: IPermission[]) =>
set(() => ({
permissions: _.uniqBy(newPermissions, 'id'),
})),
setBasePermissions: (newPermissions: IPermission[]) =>
set(() => ({
basePermission: _.uniqBy(newPermissions, 'id'),
})),
deletePermission: (permission: IPermission) =>
set((state) => ({
permissions: state.permissions.filter((p) => p.id !== permission.id),
})),
}));

View File

@ -0,0 +1,9 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/dropzone/styles.css';
import './index.css';
import { createRoot } from 'react-dom/client';
import { App } from './pages/index.ts';
createRoot(document.getElementById('root')!).render(<App />);

View File

@ -0,0 +1,54 @@
import { Button, createTheme, Group, MantineProvider, Modal } from '@mantine/core';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Login } from '.';
import { GlobalNotification } from '../components';
import { PrivateLayout } from '../layouts';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import Links from '../system/links';
const theme = createTheme({});
export default function App() {
const { props, open, close } = useConfirmStore();
return (
<BrowserRouter>
<MantineProvider theme={theme} defaultColorScheme="dark">
<Routes>
<Route path={Links.HOME} element={<Navigate to={Links.DASHBOARD} replace />} />
<Route path={Links.LOGIN} element={<Login />} />
<Route element={<PrivateLayout />}>
{Links.MENUS.map((item) => {
return <Route key={item.path} path={item.path} element={<item.element />} />;
})}
</Route>
</Routes>
<GlobalNotification />
<Modal zIndex={9999} opened={open} onClose={close} title={props?.title || 'Are you sure this action?'}>
{props?.message}
<Group mt="lg" justify="flex-end">
<Button onClick={close} variant="default">
Cancel
</Button>
<Button
onClick={
props.handleOk
? async () => {
props.handleOk?.();
close();
}
: undefined
}
color="red"
{...props.okButton}
>
{props.okButton?.value || 'Delete'}
</Button>
</Group>
</Modal>
</MantineProvider>
</BrowserRouter>
);
}

View File

@ -0,0 +1,298 @@
import { ActionIcon, Badge, Box, Menu, Text, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconAd, IconAdOff, IconEdit, IconHistory, IconMenu, IconTrash } from '@tabler/icons-react';
import _ from 'lodash';
import { useMemo, useRef, useState } from 'react';
import { deleteBid, deletesBid, getBids, toggleBid } from '../apis/bid';
import { BidModal, ShowHistoriesModal } from '../components/bid';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import { IBid } from '../system/type';
import { formatTime } from '../utils';
import { mappingStatusColors } from '../system/constants';
export default function Bids() {
const refTableFn: TRefTableFn<IBid> = useRef({});
const [clickData, setClickData] = useState<IBid | null>(null);
const { setConfirm } = useConfirmStore();
const [openedHistories, historiesModel] = useDisclosure(false);
const [openedBid, bidModal] = useDisclosure(false);
const columns: IColumn<IBid>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
},
{
key: 'lot_id',
title: 'Lot ID',
typeFilter: 'text',
},
{
key: 'model',
title: 'Model',
typeFilter: 'text',
},
{
key: 'quantity',
title: 'Qty',
typeFilter: 'number',
},
{
key: 'step_price',
title: 'Step price',
typeFilter: 'number',
},
{
key: 'max_price',
title: 'Max price',
typeFilter: 'number',
},
{
key: 'current_price',
title: 'Current price',
typeFilter: 'number',
},
{
key: 'histories',
title: 'Current bid',
typeFilter: 'none',
renderRow(row) {
const bidPrice = _.maxBy(row.histories, 'price');
return <Text>{bidPrice ? bidPrice.price : 'None'}</Text>;
},
},
{
key: 'start_bid_time',
title: 'Start bid',
typeFilter: 'text',
renderRow(row) {
return (
<Tooltip hidden={!row.start_bid_time} label={row.start_bid_time}>
<Text size="sm">{row.start_bid_time ? formatTime(row.start_bid_time, 'HH:mm:ss DD/MM/YYYY') : 'None'}</Text>
</Tooltip>
);
},
},
{
key: 'close_time',
title: 'Close time',
typeFilter: 'text',
renderRow(row) {
return (
<Tooltip hidden={!row.close_time} label={row.close_time}>
<Text size="sm">{row.close_time ? formatTime(row.close_time, 'HH:mm:ss DD/MM/YYYY') : 'None'}</Text>
</Tooltip>
);
},
},
{
key: 'status',
title: 'Status',
typeFilter: 'text',
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={mappingStatusColors[row.status]} size="sm">
{row.status}
</Badge>
</Box>
);
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
renderRow(row) {
return <span className="text-sm">{formatTime(row.updated_at)}</span>;
},
},
];
const handleDelete = (bid: IBid) => {
setConfirm({
title: 'Delete ?',
message: 'This bid will be delete',
handleOk: async () => {
await deleteBid(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggleBid = async (bid: IBid) => {
const isEnable = bid.status === 'biding' ? true : bid.status === 'out-bid' ? false : true;
setConfirm({
title: (isEnable ? 'Disable ' : 'Enable ') + 'ID: ' + bid.id,
message: 'This bid will be ' + (isEnable ? 'disable ' : 'enable '),
handleOk: async () => {
await toggleBid(bid);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: isEnable ? 'Disable ' : 'Enable ',
color: isEnable ? 'red' : 'blue',
},
});
};
const table = useMemo(() => {
return (
<Table
actionsOptions={{
actions: [
{
key: 'add',
title: 'Add',
callback: () => {
bidModal.open();
},
},
{
key: 'delete',
title: 'Delete',
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesBid(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getBids,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => {
setClickData(row);
bidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
historiesModel.open();
}}
leftSection={<IconHistory size={14} />}
>
Histories
</Menu.Item>
<Menu.Item
disabled={row.status === 'win-bid'}
onClick={() => handleToggleBid(row)}
leftSection={row.status === 'biding' ? <IconAdOff size={14} /> : <IconAd size={14} />}
>
{row.status === 'biding' ? 'Disable' : 'Enable'}
</Menu.Item>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rowKey="id"
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box>
{table}
<ShowHistoriesModal
opened={openedHistories}
onClose={() => {
historiesModel.close();
setClickData(null);
}}
data={clickData}
/>
<BidModal
onUpdated={() => {
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
setClickData(null);
}}
opened={openedBid}
onClose={() => {
bidModal.close();
setClickData(null);
}}
data={clickData}
/>
</Box>
);
}

View File

@ -0,0 +1,81 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useState } from 'react';
import { Box, Title } from '@mantine/core';
import io from 'socket.io-client';
import { WorkingPage } from '../components/dashboard';
import { IBid, IWebBid } from '../system/type';
// Dùng singleton để giữ kết nối WebSocket khi chuyển trang
const socket = io('http://localhost:4000', {
autoConnect: true, // Tránh tự động kết nối khi import file
transports: ['websocket'], // Chỉ dùng WebSocket để giảm độ trễ
});
export default function DashBoard() {
const [workingData, setWorkingData] = useState<(IWebBid & { type: string })[] | (IBid & { type: string })[]>([]);
useEffect(() => {
socket.connect();
socket.on('connect', () => {
console.log('✅ WebSocket connected:', socket.id);
// 🔥 Gửi yêu cầu lấy dữ liệu ngay khi kết nối
socket.emit('getBidsData');
});
socket.on('disconnect', () => {
console.log('❌ WebSocket disconnected');
});
socket.on('bidsUpdated', (data: IWebBid[]) => {
console.log('📢 Bids Data:', data);
const array = data.reduce((prev, cur) => {
if (cur.children?.length > 0) {
prev = [...prev, ...cur.children];
}
prev.push(cur);
return prev;
}, [] as any[]);
const newData = array.map((item) => {
if (item.children) {
return {
...item,
type: 'API_BID',
};
}
return {
...item,
type: 'PRODUCT_TAB',
};
});
setWorkingData(newData);
});
return () => {
console.log('🔌 Cleanup WebSocket listeners...');
socket.off('bidsUpdated');
socket.off('working');
socket.off('connect');
socket.off('disconnect');
socket.disconnect();
};
}, []);
return (
<Box>
<Title order={2} mb="md">
Admin Dashboard
</Title>
<Box className="grid grid-cols-4 gap-4">
{workingData.map((item, index) => (
<WorkingPage socket={socket} data={item} key={item.id + index} />
))}
</Box>
</Box>
);
}

View File

@ -0,0 +1,5 @@
export { default as Dashboard } from './dashboard';
export { default as Bids } from './bids';
export { default as OutBidsLog } from './out-bids-log';
export { default as Login } from './login';
export { default as App } from './app';

View File

@ -0,0 +1,58 @@
import { Box, Button, Divider, Group, Paper, PasswordInput, Stack, Text, TextInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { upperFirst } from '@mantine/hooks';
import { z } from 'zod';
import { login } from '../apis/auth';
import { useNavigate } from 'react-router';
import Links from '../system/links';
const loginSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
export default function Login() {
const navigate = useNavigate();
const form = useForm({
validate: zodResolver(loginSchema),
initialValues: {
username: '',
password: '',
},
});
const handleSubmit = async (values: typeof form.values) => {
const credential = await login(values);
if (credential && credential.data) {
navigate(Links.DASHBOARD);
}
};
return (
<Box className="w-screen h-screen flex items-center justify-center">
<Paper w={400} radius="md" p="xl" withBorder>
<Text size="xl" className="text-center" fw={500}>
Login to KTQ Admin
</Text>
<Divider label="" labelPosition="center" my="lg" />
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput label="Username" placeholder="Your username" {...form.getInputProps('username')} radius="md" />
<PasswordInput label="Password" placeholder="Your password" {...form.getInputProps('password')} radius="md" />
</Stack>
<Group justify="space-between" mt="xl">
<Button fullWidth type="submit" radius="xl">
{upperFirst('Login')}
</Button>
</Group>
</form>
</Paper>
</Box>
);
}

View File

@ -0,0 +1,90 @@
import { Box } from '@mantine/core';
import { useMemo, useRef } from 'react';
import { getOutBidLogs } from '../apis/out-bid-log';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { IOutBidLog } from '../system/type';
import { formatTime } from '../utils';
export default function OutBidsLog() {
const refTableFn: TRefTableFn<IOutBidLog> = useRef({});
const columns: IColumn<IOutBidLog>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
},
{
key: 'model',
title: 'Model',
typeFilter: 'text',
},
{
key: 'lot_id',
title: 'Lot ID',
typeFilter: 'text',
},
{
key: 'out_price',
title: 'Out Price',
typeFilter: 'number',
},
{
key: 'raw_data',
title: 'Raw data',
typeFilter: 'text',
},
{
key: 'created_at',
title: 'Created at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.created_at)}</span>;
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.updated_at)}</span>;
},
},
];
const table = useMemo(() => {
return (
<Table
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getOutBidLogs,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
rowKey="id"
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <Box>{table}</Box>;
}

View File

@ -0,0 +1,253 @@
import { ActionIcon, Badge, Box, Menu, Text } from '@mantine/core';
import { IconAd, IconAdOff, IconEdit, IconMenu, IconTrash, IconUserEdit } from '@tabler/icons-react';
import { useMemo, useRef, useState } from 'react';
import { deletesWebBid, deleteWebBid, getWebBids, updateWebBid } from '../apis/web-bid';
import Table from '../lib/table/table';
import { IColumn, TRefTableFn } from '../lib/table/type';
import { useConfirmStore } from '../lib/zustand/use-confirm';
import { IWebBid } from '../system/type';
import { formatTime } from '../utils';
import { WebAccountModal, WebBidModal } from '../components/web-bid';
import { useDisclosure } from '@mantine/hooks';
export default function WebBids() {
const refTableFn: TRefTableFn<IWebBid> = useRef({});
const [clickData, setClickData] = useState<IWebBid | null>(null);
const { setConfirm } = useConfirmStore();
const [webBidOpened, webBidModal] = useDisclosure(false);
const [webAccountOpened, webAccountModal] = useDisclosure(false);
const columns: IColumn<IWebBid>[] = [
{
key: 'id',
title: 'ID',
typeFilter: 'number',
},
{
key: 'origin_url',
title: 'Domain',
typeFilter: 'text',
},
{
key: 'url',
title: 'Tracking url',
typeFilter: 'text',
renderRow(row) {
return <Text>{row.url || 'None'}</Text>;
},
},
{
key: 'active',
title: 'Status',
typeFilter: 'text',
renderRow(row) {
return (
<Box className="flex items-center justify-center">
<Badge color={row.active ? 'green' : 'red'} size="sm">
{row.active ? 'Enable' : 'Disable'}
</Badge>
</Box>
);
},
},
{
key: 'created_at',
title: 'Created at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.created_at)}</span>;
},
},
{
key: 'updated_at',
title: 'Update at',
typeFilter: 'none',
renderRow(row) {
return <span>{formatTime(row.updated_at)}</span>;
},
},
];
const handleDelete = (data: IWebBid) => {
setConfirm({
title: 'Delete ?',
message: 'This web will be delete',
handleOk: async () => {
await deleteWebBid(data);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
});
};
const handleToggle = async (data: IWebBid) => {
setConfirm({
title: (data.active ? 'Disable ' : 'Enable ') + 'ID: ' + data.id,
message: 'This web will be ' + (data.active ? 'disable ' : 'enable '),
handleOk: async () => {
await updateWebBid({ ...data, active: !data.active || false });
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
},
okButton: {
value: data.active ? 'Disable ' : 'Enable ',
color: data.active ? 'red' : 'blue',
},
});
};
const table = useMemo(() => {
return (
<Table
actionsOptions={{
actions: [
{
key: 'add',
title: 'Add',
callback: () => {
webBidModal.open();
},
},
{
key: 'delete',
title: 'Delete',
callback: (data) => {
if (!data.length) return;
setConfirm({
title: 'Delete',
message: `${data.length} will be delete`,
handleOk: async () => {
const result = await deletesWebBid(data);
if (!result) return;
if (refTableFn.current.fetchData) {
refTableFn.current.fetchData();
}
},
});
},
disabled: (data) => data.length <= 0,
},
],
}}
refTableFn={refTableFn}
striped
showLoading={true}
highlightOnHover
styleDefaultHead={{
justifyContent: 'flex-start',
width: 'fit-content',
}}
options={{
query: getWebBids,
pathToData: 'data.data',
keyOptions: {
last_page: 'lastPage',
per_page: 'perPage',
from: 'from',
to: 'to',
total: 'total',
},
}}
rows={[]}
withColumnBorders
showChooses={true}
withTableBorder
columns={columns}
actions={{
title: <Box className="w-full text-center">Action</Box>,
body: (row) => {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
<Box className="flex w-full items-center justify-center">
<ActionIcon size="sm" variant="light">
<IconMenu size={14} />
</ActionIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={() => {
setClickData(row);
webBidModal.open();
}}
leftSection={<IconEdit size={14} />}
>
Edit
</Menu.Item>
<Menu.Item
onClick={() => {
setClickData(row);
webAccountModal.open();
}}
leftSection={<IconUserEdit size={14} />}
>
Account
</Menu.Item>
<Menu.Item onClick={() => handleToggle(row)} leftSection={row.active ? <IconAdOff size={14} /> : <IconAd size={14} />}>
{row.active ? 'Disable' : 'Enable'}
</Menu.Item>
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
},
}}
rowKey="id"
/>
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Box>
{table}
<WebBidModal
data={clickData}
opened={webBidOpened}
onClose={() => {
webBidModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
<WebAccountModal
data={clickData}
opened={webAccountOpened}
onClose={() => {
webAccountModal.close();
setClickData(null);
}}
onUpdated={() => {
setClickData(null);
if (refTableFn.current?.fetchData) {
refTableFn.current.fetchData();
}
}}
/>
</Box>
);
}

View File

@ -0,0 +1,5 @@
export const mappingStatusColors = {
biding: 'blue',
'out-bid': 'red',
'win-bid': 'green',
};

View File

@ -0,0 +1,39 @@
import { IconHammer, IconHome2, IconOutlet, IconPageBreak } from '@tabler/icons-react';
import { Bids, Dashboard, OutBidsLog } from '../pages';
import WebBids from '../pages/web-bids';
export default class Links {
public static DASHBOARD = '/dashboard';
public static BIDS = '/bids';
public static WEBS = '/webs';
public static OUT_BIDS_LOG = '/out-bids-log';
public static HOME = '/';
public static LOGIN = '/login';
public static MENUS = [
{
path: this.DASHBOARD,
title: 'Dashboard',
icon: IconHome2,
element: Dashboard,
},
{
path: this.WEBS,
title: 'Webs',
icon: IconPageBreak,
element: WebBids,
},
{
path: this.BIDS,
title: 'Bids',
icon: IconHammer,
element: Bids,
},
{
path: this.OUT_BIDS_LOG,
title: 'Out bids log',
icon: IconOutlet,
element: OutBidsLog,
},
];
}

View File

@ -0,0 +1,56 @@
export interface IAdmin extends ITimestamp {
id: number;
email: string;
username: string;
fullname: string;
password?: string;
is_system_account: boolean;
permissions: [];
}
export interface ITimestamp {
created_at: string;
updated_at: string;
}
export interface IBid extends ITimestamp {
id: number;
max_price: number;
current_price: number;
name: string | null;
quantity: number;
url: string;
model: string;
lot_id: string;
step_price: number;
close_time: string | null;
start_bid_time: string | null;
first_bid: boolean;
status: 'biding' | 'out-bid' | 'win-bid';
histories: IHistory[];
}
export interface IHistory extends ITimestamp {
id: number;
price: number;
}
export interface IOutBidLog extends ITimestamp {
id: number;
model: string;
lot_id: string;
out_price: number;
raw_data: string;
}
export interface IWebBid extends ITimestamp {
created_at: string;
updated_at: string;
id: number;
origin_url: string;
url: string | null;
username: string | null;
password: string | null;
active: boolean;
children: IBid[];
}

View File

@ -0,0 +1,112 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import moment from 'moment';
export function cn(...args: ClassValue[]) {
return twMerge(clsx(args));
}
export const formatTime = (time: string, patent = 'DD/MM/YYYY') => {
return moment(time).format(patent);
};
export function removeFalsyValues<T extends Record<string, any>>(obj: T, excludeKeys: (keyof T)[] = []): Partial<T> {
return Object.entries(obj).reduce((acc, [key, value]) => {
if (value || excludeKeys.includes(key as keyof T)) {
acc[key as keyof T] = value;
}
return acc;
}, {} as Partial<T>);
}
export function isValidJSON(str: string): boolean {
if (!str || str.length <= 0) return false;
try {
JSON.parse(str);
return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return false;
}
}
export function copyToClipboard(text: string, onSuccess?: () => void): void {
if (!navigator.clipboard) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand('copy');
if (onSuccess) onSuccess();
} catch (err) {
console.error('Không thể copy nội dung: ', err);
}
document.body.removeChild(textarea);
} else {
navigator.clipboard
.writeText(text)
.then(() => {
if (onSuccess) onSuccess();
})
.catch((err) => console.error('Lỗi khi copy nội dung: ', err));
}
}
export function base64ToFile(base64String: string, fileName: string): File {
const [header, base64Content] = base64String.split(',');
const mimeTypeMatch = header.match(/:(.*?);/);
if (!mimeTypeMatch || mimeTypeMatch.length < 2) {
throw new Error('Invalid base64 string');
}
const mimeType = mimeTypeMatch[1];
const binaryString = atob(base64Content);
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
return new File([byteArray], fileName, { type: mimeType });
}
export function toSlug(str: string, maxLength = 60): string {
if (typeof str !== 'string') return ''; // Kiểm tra giá trị đầu vào
// Kiểm tra nếu môi trường hỗ trợ `normalize`
const normalizedStr = str.normalize ? str.normalize('NFD') : str;
return normalizedStr
.replace(/[\u0300-\u036f]/g, '') // Xóa dấu
.replace(/[^a-zA-Z0-9\s-]/g, '') // Chỉ giữ chữ cái, số, khoảng trắng và dấu "-"
.trim() // Xóa khoảng trắng đầu/cuối
.replace(/\s+/g, '-') // Thay khoảng trắng bằng "-"
.replace(/-+/g, '-') // Gộp nhiều dấu "-" thành 1
.toLowerCase() // Chuyển về chữ thường
.slice(0, maxLength) // Giới hạn độ dài
.replace(/^-+|-+$/g, ''); // Xóa "-" đầu/cuối
}
export function estimateReadingTimeInSeconds(content: string, wordsPerMinute = 200): number {
if (!content || typeof content !== 'string') return 0;
const wordCount = content.trim().split(/\s+/).length;
return Math.ceil((wordCount / wordsPerMinute) * 60);
}
export function extractDomain(url: string): string | null {
try {
const parsedUrl = new URL(url);
return parsedUrl.origin;
} catch (error) {
return null;
}
}

1
auto-bid-admin/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@ -0,0 +1,9 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
build: {
sourcemap: false,
},
server: {
hmr: {
timeout: 30000,
overlay: true,
},
},
optimizeDeps: {
include: ['@mantine/core'],
},
});

View File

@ -0,0 +1,31 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
};

57
auto-bid-server/.gitignore vendored Normal file
View File

@ -0,0 +1,57 @@
# compiled output
/dist
/node_modules
/build
/public
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

73
auto-bid-server/README.md Normal file
View File

@ -0,0 +1,73 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="200" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://coveralls.io/github/nestjs/nest?branch=master" target="_blank"><img src="https://coveralls.io/repos/github/nestjs/nest/badge.svg?branch=master#9" alt="Coverage" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](LICENSE).

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

11277
auto-bid-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
{
"name": "auto-bid-server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^10.0.0",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/platform-socket.io": "^11.0.11",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.13.0",
"nestjs-paginate": "^11.1.0",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.5",
"typeorm": "^0.3.21"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/multer": "^1.4.12",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { BidsModule } from './modules/bids/bids.module';
import { DatabasesModule } from './modules/databases/databases.module';
import { AppConfigsModule } from './modules/app-configs/app-configs.module';
@Module({
imports: [BidsModule, DatabasesModule, AppConfigsModule],
controllers: [],
providers: [],
})
export class AppModule {}

View File

@ -0,0 +1,36 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { BadRequestExceptionFilter } from './system/filters/bad-request-exception-filter';
import { IoAdapter } from '@nestjs/platform-socket.io';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const prefix_version = '/api/v1';
app.enableCors({
origin: process.env.CORS.split(', '),
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
allowedHeaders: 'Content-Type, Authorization',
credentials: true,
});
app.setGlobalPrefix(prefix_version, {});
// Sử dụng WebSocket adapter
app.useWebSocketAdapter(new IoAdapter(app));
app.useGlobalFilters(new BadRequestExceptionFilter());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT);
}
bootstrap();

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
],
})
export class AppConfigsModule {}

View File

@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminBidHistoriesController } from './controllers/admin/admin-bid-histories.controller';
import { AdminBidsController } from './controllers/admin/admin-bids.controller';
import { AdminOutBidLogsController } from './controllers/admin/admin-out-bid-logs.controller';
import { AdminWebBidsController } from './controllers/admin/admin-web-bids.controller';
import { BidHistoriesController } from './controllers/client/bid-histories.controller';
import { BidsController } from './controllers/client/bids.controller';
import { OutBidLogsController } from './controllers/client/out-bid-logs.controller';
import { BidHistory } from './entities/bid-history.entity';
import { Bid } from './entities/bid.entity';
import { OutBidLog } from './entities/out-bid-log.entity';
import { WebBid } from './entities/wed-bid.entity';
import { BidGateway } from './getways/bid-getway';
import { BidHistoriesService } from './services/bid-histories.service';
import { BidsService } from './services/bids.service';
import { OutBidLogsService } from './services/out-bid-logs.service';
import { WebBidsService } from './services/web-bids.service';
@Module({
imports: [
TypeOrmModule.forFeature([Bid, BidHistory, OutBidLog, WebBid]),
EventEmitterModule.forRoot({
wildcard: true,
}),
],
controllers: [
BidsController,
BidHistoriesController,
OutBidLogsController,
AdminBidHistoriesController,
AdminBidsController,
AdminOutBidLogsController,
AdminWebBidsController,
],
providers: [
BidsService,
BidHistoriesService,
BidGateway,
OutBidLogsService,
WebBidsService,
],
})
export class BidsModule {}

View File

@ -0,0 +1,15 @@
import { Body, Controller, Get, Post } from '@nestjs/common';
import { BidsService } from '../../services/bids.service';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { BidHistoriesService } from '../../services/bid-histories.service';
import { CreateBidHistoryDto } from '../../dto/bid-history/create-bid-history.dto';
@Controller('admin/bid-histories')
export class AdminBidHistoriesController {
constructor(private readonly bidHistoriesService: BidHistoriesService) {}
@Post()
create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data);
}
}

View File

@ -0,0 +1,62 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
Res,
} from '@nestjs/common';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { BidsService } from '../../services/bids.service';
import { Bid } from '../../entities/bid.entity';
import { DeletesDto } from '../../dto/deletes.dto';
import { UpdateBidDto } from '../../dto/bid/update-bid.dto';
import { Response } from 'express';
@Controller('admin/bids')
export class AdminBidsController {
constructor(private readonly bidsService: BidsService) {}
@Get()
async index(@Paginate() query: PaginateQuery) {
return await this.bidsService.index(query);
}
@Post()
create(@Body() data: CreateBidDto) {
return this.bidsService.create(data);
}
@Put(':id')
update(@Param('id') id: Bid['id'], @Body() data: UpdateBidDto) {
return this.bidsService.update(id, data);
}
@Delete(':id')
async delete(@Param('id') id: Bid['id']) {
return await this.bidsService.delete(id);
}
@Post('deletes')
async deletes(@Body() data: DeletesDto) {
return await this.bidsService.deletes(data.ids);
}
@Post('toggle/:id')
toggle(@Param('id') id: Bid['id']) {
return this.bidsService.toggle(id);
}
@Get('status-working/:type/:id/:name')
async getStatusWorkingImage(
@Param('id') id: Bid['id'],
@Param('type') type: string,
@Param('name') name: string,
@Res() res: Response,
) {
return await this.bidsService.getStatusWorkingImage(id, type, name, res);
}
}

View File

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
import { OutBidLogsService } from '../../services/out-bid-logs.service';
@Controller('admin/out-bid-logs')
export class AdminOutBidLogsController {
constructor(private readonly outBidLogsService: OutBidLogsService) {}
@Get()
async index(@Paginate() query: PaginateQuery) {
return await this.outBidLogsService.index(query);
}
}

View File

@ -0,0 +1,51 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Put,
} from '@nestjs/common';
import { Paginate, PaginateQuery } from 'nestjs-paginate';
import { WebBidsService } from '../../services/web-bids.service';
import { Bid } from '../../entities/bid.entity';
import { DeletesDto } from '../../dto/deletes.dto';
import { CreateWebBidDto } from '../../dto/web-bid/create-web-bid';
import { WebBid } from '../../entities/wed-bid.entity';
import { UpdateWebBidDto } from '../../dto/web-bid/update-web-bid';
@Controller('admin/web-bids')
export class AdminWebBidsController {
constructor(private readonly webBidsService: WebBidsService) {}
@Get()
async index(@Paginate() query: PaginateQuery) {
return await this.webBidsService.index(query);
}
@Get('test')
async test(@Paginate() query: PaginateQuery) {
return await this.webBidsService.getDataClient();
}
@Post()
async create(@Body() data: CreateWebBidDto) {
return await this.webBidsService.create(data);
}
@Put(':id')
async update(@Param('id') id: WebBid['id'], @Body() data: UpdateWebBidDto) {
return await this.webBidsService.update(id, data);
}
@Delete(':id')
async delete(@Param('id') id: WebBid['id']) {
return await this.webBidsService.delete(id);
}
@Post('deletes')
async deletes(@Body() data: DeletesDto) {
return await this.webBidsService.deletes(data.ids);
}
}

View File

@ -0,0 +1,13 @@
import { Body, Controller, Post } from '@nestjs/common';
import { CreateBidHistoryDto } from '../../dto/bid-history/create-bid-history.dto';
import { BidHistoriesService } from '../../services/bid-histories.service';
@Controller('bid-histories')
export class BidHistoriesController {
constructor(private readonly bidHistoriesService: BidHistoriesService) {}
@Post()
create(@Body() data: CreateBidHistoryDto) {
return this.bidHistoriesService.create(data);
}
}

View File

@ -0,0 +1,62 @@
import {
Body,
Controller,
Get,
Param,
Post,
Put,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ClientUpdateBidDto } from '../../dto/bid/client-update-bid.dto';
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
import { Bid } from '../../entities/bid.entity';
import { BidsService } from '../../services/bids.service';
import { UpdateStatusByPriceDto } from '../../dto/bid/update-status-by-price.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { Response } from 'express';
@Controller('bids')
export class BidsController {
constructor(private readonly bidsService: BidsService) {}
@Get()
index() {
return this.bidsService.clientIndex();
}
@Post()
create(@Body() data: CreateBidDto) {
return this.bidsService.create(data);
}
@Put(':id')
update(@Param('id') id: Bid['id'], @Body() data: ClientUpdateBidDto) {
return this.bidsService.clientUpdate(id, data);
}
@Post('out-bid/:id')
outBidStatus(@Param('id') id: Bid['id']) {
return this.bidsService.outBid(id);
}
@Post('update-status/:id')
updateStatusByPrice(
@Param('id') id: Bid['id'],
@Body() data: UpdateStatusByPriceDto,
) {
return this.bidsService.updateStatusByPrice(id, data);
}
@Post('update-status-work/:type/:id')
@UseInterceptors(FileInterceptor('image', { storage: memoryStorage() }))
async updateStatusWork(
@Param('id') id: Bid['id'],
@Param('type') type: string,
@UploadedFile() image: Express.Multer.File,
) {
return this.bidsService.updateStatusWork(id, type, image);
}
}

View File

@ -0,0 +1,13 @@
import { Body, Controller, Post } from '@nestjs/common';
import { CreateOutBidLogsDto } from '../../dto/out-bid-log/create-out-bid-logs.dto';
import { OutBidLogsService } from '../../services/out-bid-logs.service';
@Controller('out-bid-logs')
export class OutBidLogsController {
constructor(private readonly outBidLogsService: OutBidLogsService) {}
@Post()
create(@Body() data: CreateOutBidLogsDto) {
return this.outBidLogsService.create(data.data);
}
}

View File

@ -0,0 +1,10 @@
import { IsNumber } from 'class-validator';
import { Bid } from '../../entities/bid.entity';
export class CreateBidHistoryDto {
@IsNumber()
bid_id: Bid['id'];
@IsNumber()
price: number;
}

View File

@ -0,0 +1,18 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
export class ClientUpdateBidDto {
@IsString()
@IsOptional()
lot_id: string;
@IsString()
close_time: string;
@IsString()
@IsOptional()
name: string;
@IsNumber()
@IsOptional()
current_price: number;
}

View File

@ -0,0 +1,24 @@
import { Optional } from '@nestjs/common';
import {
IsBoolean,
IsNumber,
IsOptional,
IsString,
IsUrl,
} from 'class-validator';
export class CreateBidDto {
@IsUrl()
url: string;
@IsNumber()
max_price: number;
@IsNumber()
@IsOptional()
quantity: number;
@IsNumber()
@IsOptional()
step_price: number;
}

View File

@ -0,0 +1,15 @@
import { IsNumber, IsOptional } from 'class-validator';
export class UpdateBidDto {
@IsNumber()
@IsOptional()
max_price: number;
@IsNumber()
@IsOptional()
quantity: number;
@IsNumber()
@IsOptional()
step_price: number;
}

View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class UpdateStatusByPriceDto {
@IsNumber()
current_price: number;
}

View File

@ -0,0 +1,6 @@
import { IsNumber } from 'class-validator';
export class DeletesDto {
@IsNumber({}, { each: true })
ids: number[];
}

View File

@ -0,0 +1,15 @@
import { IsNumber, IsString } from 'class-validator';
export class CreateOutBidLogDto {
@IsString()
model: string;
@IsString()
lot_id: string;
@IsNumber()
out_price: number;
@IsString()
raw_data: string;
}

View File

@ -0,0 +1,7 @@
import { IsArray, IsNumber, IsString } from 'class-validator';
import { CreateOutBidLogDto } from './create-out-bid-log.dto';
export class CreateOutBidLogsDto {
@IsArray()
data: CreateOutBidLogDto[];
}

View File

@ -0,0 +1,9 @@
import { IsNumber, IsString, IsUrl } from 'class-validator';
export class CreateWebBidDto {
@IsUrl()
origin_url: string;
@IsUrl()
url: string;
}

View File

@ -0,0 +1,23 @@
import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator';
export class UpdateWebBidDto {
@IsUrl()
@IsOptional()
origin_url: string;
@IsUrl()
@IsOptional()
url: string;
@IsString()
@IsOptional()
username: string;
@IsString()
@IsOptional()
password: string;
@IsBoolean()
@IsOptional()
active: boolean;
}

View File

@ -0,0 +1,15 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
@Entity('bid_histories')
export class BidHistory extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ default: 0 })
price: number;
@ManyToOne(() => Bid, (bid) => bid.histories, { onDelete: 'CASCADE' })
bid: Bid;
}

View File

@ -0,0 +1,60 @@
import {
Column,
Entity,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Timestamp } from './timestamp';
import { BidHistory } from './bid-history.entity';
import { WebBid } from './wed-bid.entity';
@Entity('bids')
export class Bid extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column()
max_price: number;
@Column({ default: 1 })
quantity: number;
@Column()
url: string;
@Column({ unique: true, default: null, nullable: true })
model: string | null;
@Column({ default: null, nullable: true })
lot_id: string;
@Column({ default: 0 })
step_price: number;
@Column({ default: null, nullable: true })
name: string;
@Column({ default: 0 })
current_price: number;
@Column({ default: null, nullable: true })
close_time: string;
@Column({ default: null, nullable: true })
start_bid_time: string;
@Column({ default: true })
first_bid: boolean;
@Column({ default: 'biding' })
status: 'biding' | 'out-bid' | 'win-bid';
@OneToMany(() => BidHistory, (bidHistory) => bidHistory.bid, {
cascade: true,
})
histories: BidHistory[];
@ManyToOne(() => WebBid, (web) => web.children, { onDelete: 'CASCADE' })
web_bid: WebBid;
}

View File

@ -0,0 +1,20 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { Timestamp } from './timestamp';
@Entity('out-bid-logs')
export class OutBidLog extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true, default: null, nullable: true })
model: string | null;
@Column({ default: null, nullable: true })
lot_id: string;
@Column()
out_price: number;
@Column({ default: null, nullable: true })
raw_data: string;
}

View File

@ -0,0 +1,8 @@
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
export abstract class Timestamp {
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
created_at: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
updated_at: Date;
}

View File

@ -0,0 +1,29 @@
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Timestamp } from './timestamp';
import { Bid } from './bid.entity';
@Entity('web_bids')
export class WebBid extends Timestamp {
@PrimaryGeneratedColumn('increment')
id: number;
@Column({ unique: true })
origin_url: string;
@Column({ default: null, nullable: true })
url: string;
@Column({ default: null, nullable: true })
username: string;
@Column({ default: null, nullable: true })
password: string;
@Column({ default: true })
active: boolean;
@OneToMany(() => Bid, (bid) => bid.web_bid, {
cascade: true,
})
children: Bid[];
}

View File

@ -0,0 +1,46 @@
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
OnGatewayConnection,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { BidsService } from '../services/bids.service';
import { WebBidsService } from '../services/web-bids.service';
@WebSocketGateway({
cors: {
origin: '*',
methods: ['GET', 'POST'],
credentials: true,
},
})
export class BidGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(
private eventEmitter: EventEmitter2,
private webBidsService: WebBidsService,
private bidsService: BidsService,
) {}
async onModuleInit() {
this.eventEmitter.on('bids.updated', (data) => {
this.server.emit('bidsUpdated', data); // Gửi sự kiện WebSocket đến client
});
this.eventEmitter.on('working', (data) => {
this.server.emit('working', data);
});
}
async handleConnection(client: Socket) {
console.log(`📢 Client connected: ${client.id}`);
const data = await this.webBidsService.getDataClient();
// Gửi dữ liệu bids ngay khi client kết nối
client.emit('bidsUpdated', data);
}
}

View File

@ -0,0 +1,67 @@
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { existsSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
import * as path from 'path';
import * as sharp from 'sharp';
import { ALLOWED_IMAGE_EXT } from '../utils/file-store';
import { Constant } from '../utils/constant';
@Injectable()
export class ImageCompressionPipe implements PipeTransform {
constructor(
private folder: string = Constant.WORK_IMAGES_FOLDER,
private resize: { width?: number; height?: number },
private options: {
unique_name?: boolean;
unique_image_folder?: boolean;
} = { unique_name: true, unique_image_folder: false },
) {}
async transform(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No file provided');
}
const ext = path.extname(file.originalname).toLowerCase();
if (!ALLOWED_IMAGE_EXT.includes(ext)) {
throw new BadRequestException(
`File "${file.originalname}" is not a valid image.`,
);
}
const uploadDir = `public/${this.folder}`;
if (!existsSync(uploadDir)) {
mkdirSync(uploadDir, { recursive: true });
} else {
if (this.options.unique_image_folder) {
const files = readdirSync(uploadDir);
files.forEach((file) => {
const filePath = path.join(uploadDir, file);
if (/\.(jpg|jpeg|png|webp)$/i.test(file)) {
unlinkSync(filePath);
}
});
}
}
const filename = this.options.unique_name
? `${Date.now()}-${this.resize.width || Constant.DEFAULT_RESIZE}x${this.resize.height || Constant.DEFAULT_RESIZE}-${file.originalname.replace(ext, '.webp')}`
: file.originalname.replace(ext, '.webp');
const filePath = path.join(uploadDir, filename);
try {
await sharp(file.buffer)
// .resize({
// width: this.resize?.width || Constant.DEFAULT_RESIZE,
// height: this.resize?.height || Constant.DEFAULT_RESIZE,
// })
.webp({ effort: 3 })
.toFile(filePath);
return { filename };
} catch (error) {
throw new BadRequestException(
`Error compressing image: ${error.message}`,
);
}
}
}

Some files were not shown because too many files have changed in this diff Show More