mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2025-12-30 19:50:07 +01:00
(feature): migrate to cutom react from next init
This commit is contained in:
parent
81d602e381
commit
edacd27aef
@ -1,38 +1,16 @@
|
||||
# Use the specific Node.js version 20.14.0 image as the base image for building the app
|
||||
FROM node:20.14.0-alpine AS build
|
||||
FROM node:20.14.0-alpine
|
||||
|
||||
# Set the working directory inside the container
|
||||
# Ambiente di sviluppo
|
||||
ENV NODE_ENV=development
|
||||
ENV LETSENCRYPT_SERVER=http://localhost:8080
|
||||
ENV CHOKIDAR_USEPOLLING=true
|
||||
ENV WATCHPACK_POLLING=true
|
||||
|
||||
# Lavora come utente normale
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and package-lock.json files to the working directory
|
||||
COPY ClientApp/package*.json ./
|
||||
|
||||
# Install the project dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code to the working directory
|
||||
COPY ClientApp .
|
||||
|
||||
# Build the Next.js application for production
|
||||
RUN npm run build
|
||||
|
||||
# Use the same Node.js image for running the app
|
||||
FROM node:20.14.0-alpine AS production
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and package-lock.json files to the working directory
|
||||
COPY ClientApp/package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy the built Next.js application from the build stage to the current directory
|
||||
COPY --from=build /app ./
|
||||
|
||||
# Expose port 3000 to access the application
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the Next.js server
|
||||
CMD ["npm", "start"]
|
||||
# Comando di avvio
|
||||
CMD ["sh", "-c", "npm install && npm run dev"]
|
||||
|
||||
38
src/ClientApp/Dockerfile.prod
Normal file
38
src/ClientApp/Dockerfile.prod
Normal file
@ -0,0 +1,38 @@
|
||||
# Use the specific Node.js version 20.14.0 image as the base image for building the app
|
||||
FROM node:20.14.0-alpine AS build
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and package-lock.json files to the working directory
|
||||
COPY ClientApp/package*.json ./
|
||||
|
||||
# Install the project dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code to the working directory
|
||||
COPY ClientApp .
|
||||
|
||||
# Build the Next.js application for production
|
||||
RUN npm run build
|
||||
|
||||
# Use the same Node.js image for running the app
|
||||
FROM node:20.14.0-alpine AS production
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the package.json and package-lock.json files to the working directory
|
||||
COPY ClientApp/package*.json ./
|
||||
|
||||
# Install only production dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy the built Next.js application from the build stage to the current directory
|
||||
COPY --from=build /app ./
|
||||
|
||||
# Expose port 3000 to access the application
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the Next.js server
|
||||
CMD ["npm", "start"]
|
||||
@ -38,13 +38,13 @@ export default function Page() {
|
||||
|
||||
const fetchAccounts = async () => {
|
||||
const newAccounts: CacheAccount[] = []
|
||||
const gatAccountsResult = await httpService.get<GetAccountResponse[]>(
|
||||
const getAccountsResult = await httpService.get<GetAccountResponse[]>(
|
||||
GetApiRoute(ApiRoutes.ACCOUNTS)
|
||||
)
|
||||
|
||||
if (!gatAccountsResult.isSuccess) return
|
||||
if (!getAccountsResult.isSuccess) return
|
||||
|
||||
gatAccountsResult.data?.forEach((account) => {
|
||||
getAccountsResult.data?.forEach((account) => {
|
||||
newAccounts.push(toCacheAccount(account))
|
||||
})
|
||||
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
||||
import { PageContainer } from '@/components/pageContainer'
|
||||
import { CustomButton } from '@/controls'
|
||||
import { showToast } from '@/redux/slices/toastSlice'
|
||||
import { useAppDispatch } from '@/redux/store'
|
||||
import { httpService } from '@/services/HttpService'
|
||||
|
||||
const TestPage = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const handleTestAgent = async () => {
|
||||
httpService
|
||||
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
|
||||
.then((response) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
message: JSON.stringify(response),
|
||||
type: 'info'
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer title="CertsUI Tests">
|
||||
<CustomButton
|
||||
type="button"
|
||||
onClick={handleTestAgent}
|
||||
className="bg-green-500 text-white p-2 rounded ml-2"
|
||||
>
|
||||
Test Agent
|
||||
</CustomButton>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestPage
|
||||
78
src/ClientApp/app/utils/page.tsx
Normal file
78
src/ClientApp/app/utils/page.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
|
||||
import { ApiRoutes, GetApiRoute } from '@/ApiRoutes'
|
||||
import { PageContainer } from '@/components/pageContainer'
|
||||
import { CustomButton, CustomFileUploader } from '@/controls'
|
||||
import { showToast } from '@/redux/slices/toastSlice'
|
||||
import { useAppDispatch } from '@/redux/store'
|
||||
import { httpService } from '@/services/HttpService'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const TestPage = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [files, setFiles] = useState<File[] | null>(null)
|
||||
|
||||
const handleTestAgent = async () => {
|
||||
httpService
|
||||
.get<string>(GetApiRoute(ApiRoutes.AGENT_TEST))
|
||||
.then((response) => {
|
||||
dispatch(
|
||||
showToast({
|
||||
message: JSON.stringify(response),
|
||||
type: 'info'
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownloadCache = async () => {}
|
||||
|
||||
const handleUploadCache = async () => {}
|
||||
|
||||
const handleRestoreFromCache = async () => {}
|
||||
|
||||
useEffect(() => {
|
||||
if (files && files.length > 0) {
|
||||
handleUploadCache()
|
||||
}
|
||||
}, [files])
|
||||
|
||||
return (
|
||||
<PageContainer title="CertsUI Utils">
|
||||
<CustomButton
|
||||
type="button"
|
||||
onClick={handleTestAgent}
|
||||
className="bg-green-500 text-white p-2 rounded ml-2"
|
||||
>
|
||||
Test Agent
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="button"
|
||||
onClick={handleRestoreFromCache}
|
||||
className="bg-yellow-500 text-white p-2 rounded ml-2"
|
||||
>
|
||||
Restore from cache
|
||||
</CustomButton>
|
||||
|
||||
<CustomButton
|
||||
type="button"
|
||||
onClick={handleDownloadCache}
|
||||
className="bg-blue-500 text-white p-2 rounded ml-2"
|
||||
>
|
||||
Download cache
|
||||
</CustomButton>
|
||||
|
||||
<CustomFileUploader
|
||||
value={files}
|
||||
onChange={setFiles}
|
||||
accept=".zip"
|
||||
buttonClassName="bg-blue-500 text-white p-2 rounded ml-2"
|
||||
title="Upload cache"
|
||||
/>
|
||||
</PageContainer>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestPage
|
||||
@ -16,7 +16,7 @@ interface SideMenuProps {
|
||||
const menuItems = [
|
||||
{ icon: <FaSyncAlt />, label: 'Auto Renew', path: '/' },
|
||||
{ icon: <FaUserPlus />, label: 'Register', path: '/register' },
|
||||
{ icon: <FaThermometerHalf />, label: 'Test', path: '/test' }
|
||||
{ icon: <FaThermometerHalf />, label: 'Utils', path: '/utils' }
|
||||
]
|
||||
|
||||
const SideMenu: FC<SideMenuProps> = ({ isCollapsed, toggleSidebar }) => {
|
||||
|
||||
98
src/ClientApp/controls/customFileUploader.tsx
Normal file
98
src/ClientApp/controls/customFileUploader.tsx
Normal file
@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
import React, { FC, useRef } from 'react'
|
||||
import { CustomButton } from './customButton'
|
||||
|
||||
interface CustomFileUploaderProps {
|
||||
value?: File[] | null
|
||||
onChange?: (files: File[] | null) => void
|
||||
label?: string
|
||||
labelClassName?: string
|
||||
error?: string
|
||||
errorClassName?: string
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
inputClassName?: string
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
const CustomFileUploader: FC<CustomFileUploaderProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
labelClassName = '',
|
||||
error,
|
||||
errorClassName = '',
|
||||
className = '',
|
||||
buttonClassName = '',
|
||||
inputClassName = '',
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
accept,
|
||||
multiple = false,
|
||||
title
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!readOnly && !disabled) {
|
||||
const files = e.target.files ? Array.from(e.target.files) : null
|
||||
onChange?.(files && files.length > 0 ? files : null)
|
||||
// Reset input value so the same file can be selected again
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center ${className}`}>
|
||||
{label && <label className={`mb-1 ${labelClassName}`}>{label}</label>}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
/>
|
||||
{value && value.length > 0 && (
|
||||
<>
|
||||
<span className="flex flex-row gap-2 mr-2 text-sm text-gray-600">
|
||||
{value.map((file, idx) => (
|
||||
<span key={file.name + idx}>{file.name}</span>
|
||||
))}
|
||||
</span>
|
||||
<CustomButton
|
||||
onClick={() => {
|
||||
onChange?.(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}}
|
||||
className={buttonClassName + ' ml-1'}
|
||||
disabled={disabled || readOnly}
|
||||
type="button"
|
||||
>
|
||||
✕
|
||||
</CustomButton>
|
||||
</>
|
||||
)}
|
||||
<CustomButton
|
||||
onClick={() => {
|
||||
if (!disabled && !readOnly) fileInputRef.current?.click()
|
||||
}}
|
||||
className={buttonClassName}
|
||||
disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
</CustomButton>
|
||||
{error && (
|
||||
<span className={`text-red-500 mt-1 ${errorClassName}`}>{error}</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { CustomFileUploader }
|
||||
@ -4,6 +4,7 @@ import { CustomCheckbox } from './customCheckbox'
|
||||
import { CustomSelect } from './customSelect'
|
||||
import { CustomEnumSelect } from './customEnumSelect'
|
||||
import { CustomRadioGroup } from './customRadioGroup'
|
||||
import { CustomFileUploader } from './customFileUploader'
|
||||
|
||||
export {
|
||||
CustomButton,
|
||||
@ -11,5 +12,6 @@ export {
|
||||
CustomCheckbox,
|
||||
CustomSelect,
|
||||
CustomEnumSelect,
|
||||
CustomRadioGroup
|
||||
CustomRadioGroup,
|
||||
CustomFileUploader
|
||||
}
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
const nextConfig = {}
|
||||
|
||||
|
||||
|
||||
export default nextConfig
|
||||
|
||||
5775
src/ClientApp/package-lock.json
generated
5775
src/ClientApp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -9,28 +9,30 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@reduxjs/toolkit": "^2.2.5",
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-icons": "^5.2.1",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-toastify": "^10.0.5",
|
||||
"uuid": "^10.0.0"
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"next": "16.0.1",
|
||||
"react": "^19",
|
||||
"react-dom": "^19",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@types/node": "^24",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.2",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@ -10,7 +14,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
@ -18,9 +22,20 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Core" Version="1.5.1" />
|
||||
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
|
||||
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MaksIT.Results" Version="1.0.9" />
|
||||
<PackageReference Include="MaksIT.Results" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||
|
||||
3
src/MaksIT.WebUI/.env
Normal file
3
src/MaksIT.WebUI/.env
Normal file
@ -0,0 +1,3 @@
|
||||
VITE_APP_TITLE=MaksIT.CertsUI
|
||||
VITE_COMPANY=MaksIT
|
||||
VITE_API_URL=http://localhost:8080/api
|
||||
15
src/MaksIT.WebUI/.vscode/launch.json
vendored
Normal file
15
src/MaksIT.WebUI/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
12
src/MaksIT.WebUI/.vscode/settings.json
vendored
Normal file
12
src/MaksIT.WebUI/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"eslint.format.enable": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
]
|
||||
}
|
||||
13
src/MaksIT.WebUI/Dockerfile
Normal file
13
src/MaksIT.WebUI/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM node:24
|
||||
|
||||
# Ambiente di sviluppo
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Lavora come utente normale
|
||||
USER node
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 5173
|
||||
|
||||
# Comando di avvio
|
||||
CMD ["sh", "-c", "npm install && npm run dev"]
|
||||
1
src/MaksIT.WebUI/Dockerfile.prod
Normal file
1
src/MaksIT.WebUI/Dockerfile.prod
Normal file
@ -0,0 +1 @@
|
||||
FROM node:24
|
||||
25
src/MaksIT.WebUI/New Text Document.txt
Normal file
25
src/MaksIT.WebUI/New Text Document.txt
Normal file
@ -0,0 +1,25 @@
|
||||
npm create vite@latest my-react-app
|
||||
|
||||
npm install -D tailwindcss postcss autoprefixer
|
||||
npx tailwindcss init -p
|
||||
|
||||
tailwind.config.js
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
|
||||
index.css
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
50
src/MaksIT.WebUI/README.md
Normal file
50
src/MaksIT.WebUI/README.md
Normal 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,
|
||||
},
|
||||
})
|
||||
```
|
||||
50
src/MaksIT.WebUI/eslint.config.js
Normal file
50
src/MaksIT.WebUI/eslint.config.js
Normal file
@ -0,0 +1,50 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
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: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
react, // Import and use the React plugin
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'@typescript-eslint': tseslint.plugin,
|
||||
},
|
||||
rules: {
|
||||
// react plugin rules
|
||||
'react/jsx-curly-brace-presence': [
|
||||
'error',
|
||||
{ props: 'always', children: 'never' }
|
||||
],
|
||||
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// react-refresh plugin rules
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
|
||||
// ESLint core rules
|
||||
'quotes': ['error', 'single'], // Enforce single quotes
|
||||
'semi': ['error', 'never'], // Disallow semicolons
|
||||
'indent': ['error', 2], // Enforce 2-space indentation
|
||||
'jsx-quotes': ['error', 'prefer-single'], // Enforce single quotes in JSX attributes
|
||||
|
||||
// @typescript-eslint plugin rules
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_', 'ignoreRestSiblings': true }],
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
13
src/MaksIT.WebUI/index.html
Normal file
13
src/MaksIT.WebUI/index.html
Normal 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>MaksIT.CertsUI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6214
src/MaksIT.WebUI/package-lock.json
generated
Normal file
6214
src/MaksIT.WebUI/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
src/MaksIT.WebUI/package.json
Normal file
53
src/MaksIT.WebUI/package.json
Normal file
@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "maksit-vault",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.11.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.536.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"react-virtualized": "^9.22.6",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@types/react-virtualized": "^9.22.2",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "^5.8.0",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^7.0.6"
|
||||
}
|
||||
}
|
||||
BIN
src/MaksIT.WebUI/public/logo.png
Normal file
BIN
src/MaksIT.WebUI/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
1
src/MaksIT.WebUI/public/vite.svg
Normal file
1
src/MaksIT.WebUI/public/vite.svg
Normal 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
18
src/MaksIT.WebUI/src/App.tsx
Normal file
18
src/MaksIT.WebUI/src/App.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
import { Routes } from 'react-router-dom'
|
||||
import { GetRoutes } from './AppMap'
|
||||
import { Loader } from './components/Loader'
|
||||
|
||||
const App = () => {
|
||||
|
||||
return <>
|
||||
<Routes>
|
||||
{GetRoutes()}
|
||||
</Routes>
|
||||
<Loader />
|
||||
</>
|
||||
}
|
||||
|
||||
export {
|
||||
App
|
||||
}
|
||||
232
src/MaksIT.WebUI/src/AppMap.tsx
Normal file
232
src/MaksIT.WebUI/src/AppMap.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import { Link, Route } from 'react-router-dom'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { ComponentType, FC, ReactNode } from 'react'
|
||||
import { Layout } from './components/Layout'
|
||||
import { LoginScreen } from './components/LoginScreen'
|
||||
import { Authorization } from './components/Authorization'
|
||||
import { UserOffcanvas } from './components/UserOffcanvas'
|
||||
import { UserButton } from './components/UserButton'
|
||||
import { Toast } from './components/Toast'
|
||||
import { UtilitiesPage } from './pages/UtilitiesPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
|
||||
|
||||
interface LayoutWrapperProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const LayoutWrapper: FC<LayoutWrapperProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <Layout
|
||||
sideMenu={
|
||||
{
|
||||
headerChildren: <p>{import.meta.env.VITE_APP_TITLE}</p> ,
|
||||
children: <ul>
|
||||
{GetMenuItems(LinkArea.SideMenu)}
|
||||
</ul>,
|
||||
footerChildren: <></>
|
||||
}
|
||||
}
|
||||
header={
|
||||
{
|
||||
children: <>
|
||||
<ul className={'flex space-x-4'}>
|
||||
{/* <li>Item 1</li> */}
|
||||
</ul>
|
||||
<ul className={'flex space-x-4'}>
|
||||
<li>
|
||||
<UserButton />
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
}
|
||||
}
|
||||
footer={
|
||||
{
|
||||
children: <p>© {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}</p>
|
||||
}
|
||||
}
|
||||
>{children}</Layout>
|
||||
}
|
||||
|
||||
interface AppMapType {
|
||||
title: string,
|
||||
routes: string[],
|
||||
page: ComponentType,
|
||||
useAuth?: boolean,
|
||||
useLayout?: boolean
|
||||
linkArea?: LinkArea []
|
||||
}
|
||||
|
||||
enum LinkArea {
|
||||
SideMenu,
|
||||
TopMenuLeft,
|
||||
TopMenuRigt
|
||||
}
|
||||
|
||||
const AppMap: AppMapType[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
routes: ['/', '/home'],
|
||||
page: HomePage,
|
||||
linkArea: [LinkArea.SideMenu]
|
||||
},
|
||||
{
|
||||
title: 'Register',
|
||||
routes: ['/register'],
|
||||
page: RegisterPage,
|
||||
linkArea: [LinkArea.SideMenu]
|
||||
},
|
||||
{
|
||||
title: 'Utilities',
|
||||
routes: ['/utilities'],
|
||||
page: UtilitiesPage,
|
||||
linkArea: [LinkArea.SideMenu]
|
||||
}
|
||||
|
||||
// {
|
||||
// title: 'Login',
|
||||
// routes: ['/login'],
|
||||
// page: LoginScreen,
|
||||
// useAuth: false,
|
||||
// useLayout: false
|
||||
// },
|
||||
// {
|
||||
// title: 'About',
|
||||
// routes: ['/about'],
|
||||
// page: Home
|
||||
// },
|
||||
// {
|
||||
// title: 'User',
|
||||
// routes: ['/user/:userId'],
|
||||
// page: Users
|
||||
// },
|
||||
// {
|
||||
// title: 'Organizations',
|
||||
// routes: ['/organizations', '/organization/:organizationId'],
|
||||
// page: Organizations,
|
||||
// linkArea: [LinkArea.SideMenu]
|
||||
// },
|
||||
// {
|
||||
// title: 'Applications',
|
||||
// routes: ['/applications'],
|
||||
// page: Applications,
|
||||
// linkArea: [LinkArea.SideMenu]
|
||||
// },
|
||||
// {
|
||||
// title: 'Secrets',
|
||||
// routes: ['/secrets'],
|
||||
// page: Secrets,
|
||||
// linkArea: [LinkArea.SideMenu]
|
||||
// },
|
||||
// {
|
||||
// title: 'Users',
|
||||
// routes: ['/users'],
|
||||
// page: Users,
|
||||
// linkArea: [LinkArea.SideMenu]
|
||||
// },
|
||||
// {
|
||||
// title: 'API Keys',
|
||||
// routes: ['/apikeys'],
|
||||
// page: ApiKeys,
|
||||
// linkArea: [LinkArea.SideMenu]
|
||||
// }
|
||||
]
|
||||
|
||||
// AGENT_TEST = 'api/agent/test',
|
||||
|
||||
|
||||
|
||||
|
||||
enum ApiRoutes {
|
||||
ACCOUNTS = 'GET|/accounts',
|
||||
|
||||
ACCOUNT_POST = 'POST|/account',
|
||||
ACCOUNT_GET = 'GET|/account/{accountId}',
|
||||
ACCOUNT_PATCH = 'PATCH|/account/{accountId}',
|
||||
ACCOUNT_DELETE = 'DELETE|/account/{accountId}',
|
||||
|
||||
// ACCOUNT_ID_CONTACTS = 'GET|/account/{accountId}/contacts',
|
||||
// ACCOUNT_ID_CONTACT_ID = 'GET|/account/{accountId}/contact/{index}',
|
||||
|
||||
// ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames',
|
||||
// ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}',
|
||||
|
||||
// Secrets
|
||||
generateSecret = 'GET|/secret/generatesecret',
|
||||
|
||||
// Identity
|
||||
identityLogin = 'POST|/identity/login',
|
||||
identityRefresh = 'POST|/identity/refresh',
|
||||
identityLogout = 'POST|/identity/logout',
|
||||
}
|
||||
|
||||
const GetMenuItems = (linkArea: LinkArea) => {
|
||||
return AppMap.filter(item => item.linkArea?.includes(linkArea)).map((item, index) => {
|
||||
return <li key={index}><Link to={item.routes[0]}>{item.title}</Link></li>
|
||||
})
|
||||
}
|
||||
|
||||
const GetRoutes = () => {
|
||||
return AppMap.flatMap((item) =>
|
||||
item.routes.map((route) => {
|
||||
const {
|
||||
useAuth = true,
|
||||
useLayout = true,
|
||||
page: Page
|
||||
} = item
|
||||
|
||||
const PageComponent = (
|
||||
<>
|
||||
{useLayout ? (
|
||||
<LayoutWrapper>
|
||||
<Page />
|
||||
</LayoutWrapper>
|
||||
) : (
|
||||
<Page />
|
||||
)}
|
||||
<Toast />
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Route
|
||||
key={route}
|
||||
path={route}
|
||||
// element={useAuth
|
||||
// ? <Authorization>
|
||||
// {PageComponent}
|
||||
// <UserOffcanvas />
|
||||
// </Authorization>
|
||||
// : PageComponent}
|
||||
|
||||
element={PageComponent}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
interface ApiRoute {
|
||||
method: string,
|
||||
route: string
|
||||
}
|
||||
|
||||
const GetApiRoute = (apiRoute: ApiRoutes): ApiRoute => {
|
||||
const apiUrl = import.meta.env.VITE_API_URL
|
||||
|
||||
const [method, route] = apiRoute.split('|')
|
||||
|
||||
return {
|
||||
method,
|
||||
route: `${apiUrl}${route}`
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
GetMenuItems,
|
||||
GetRoutes,
|
||||
ApiRoutes,
|
||||
GetApiRoute
|
||||
}
|
||||
1
src/MaksIT.WebUI/src/assets/react.svg
Normal file
1
src/MaksIT.WebUI/src/assets/react.svg
Normal 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 |
160
src/MaksIT.WebUI/src/axiosConfig.ts
Normal file
160
src/MaksIT.WebUI/src/axiosConfig.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import axios from 'axios'
|
||||
import { readIdentity } from './localStorage/identity'
|
||||
import { ApiRoutes, GetApiRoute } from './AppMap'
|
||||
import { store } from './redux/store'
|
||||
import { refreshJwt } from './redux/slices/identitySlice'
|
||||
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
|
||||
|
||||
// Create an Axios instance
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000, // Set a timeout if needed
|
||||
})
|
||||
|
||||
|
||||
let isRefreshing = false
|
||||
let refreshPromise: Promise<unknown> | null = null
|
||||
|
||||
// Add a request interceptor
|
||||
axiosInstance.interceptors.request.use(
|
||||
async config => {
|
||||
// Dispatch request
|
||||
store.dispatch(showLoader())
|
||||
|
||||
// List of URLs to exclude from adding Bearer token
|
||||
const excludeUrls = [
|
||||
GetApiRoute(ApiRoutes.identityLogin).route,
|
||||
GetApiRoute(ApiRoutes.identityRefresh).route
|
||||
]
|
||||
|
||||
// Check if the URL is in the exclude list
|
||||
if (config.url && excludeUrls.includes(config.url)) {
|
||||
return config
|
||||
}
|
||||
|
||||
const identity = readIdentity()
|
||||
const now = new Date()
|
||||
|
||||
if (identity) {
|
||||
if (new Date(identity.expiresAt) < now) {
|
||||
// Token expired, refresh if possible
|
||||
if (new Date(identity.refreshTokenExpiresAt) > now) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
refreshPromise = store.dispatch(refreshJwt())
|
||||
.finally(() => { isRefreshing = false })
|
||||
}
|
||||
await refreshPromise
|
||||
const newIdentity = readIdentity()
|
||||
if (newIdentity) {
|
||||
config.headers.Authorization = `${newIdentity.tokenType} ${newIdentity.token}`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
config.headers.Authorization = `${identity.tokenType} ${identity.token}`
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
// Handle request error
|
||||
store.dispatch(hideLoader())
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Add a response interceptor
|
||||
axiosInstance.interceptors.response.use(
|
||||
response => {
|
||||
// Dispatch request end
|
||||
store.dispatch(hideLoader())
|
||||
return response
|
||||
},
|
||||
error => {
|
||||
// Handle response error
|
||||
store.dispatch(hideLoader())
|
||||
if (error.response && error.response.status === 401) {
|
||||
// Handle unauthorized error (e.g., redirect to login)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
const getData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
|
||||
try {
|
||||
const response = await axiosInstance.get<TResponse>(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const postData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||
try {
|
||||
const response = await axiosInstance.post<TResponse>(url, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const patchData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||
try {
|
||||
const response = await axiosInstance.patch<TResponse>(url, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const putData = async <TRequest, TResponse>(url: string, data: TRequest): Promise<TResponse | undefined> => {
|
||||
try {
|
||||
const response = await axiosInstance.put<TResponse>(url, data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const deleteData = async <TResponse>(url: string): Promise<TResponse | undefined> => {
|
||||
try {
|
||||
const response = await axiosInstance.delete<TResponse>(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
axiosInstance,
|
||||
getData,
|
||||
postData,
|
||||
patchData,
|
||||
putData,
|
||||
deleteData
|
||||
}
|
||||
50
src/MaksIT.WebUI/src/components/Authorization.tsx
Normal file
50
src/MaksIT.WebUI/src/components/Authorization.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||
import { setIdentityFromLocalStorage } from '../redux/slices/identitySlice'
|
||||
|
||||
interface AuthorizationProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Authorization = (props: AuthorizationProps) => {
|
||||
const { children } = props
|
||||
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const { identity } = useAppSelector((state) => state.identity)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setIdentityFromLocalStorage())
|
||||
setLoading(false)
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
}
|
||||
}, [identity, navigate, loading])
|
||||
|
||||
// Render a simple loading spinner while loading (Tailwind v4 compatible)
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'flex items-center justify-center h-screen w-screen bg-white'}>
|
||||
<div className={'animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500'}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export {
|
||||
Authorization
|
||||
}
|
||||
433
src/MaksIT.WebUI/src/components/DataTable/DataTable.tsx
Normal file
433
src/MaksIT.WebUI/src/components/DataTable/DataTable.tsx
Normal file
@ -0,0 +1,433 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react'
|
||||
import { AutoSizer, MultiGrid, GridCellProps } from 'react-virtualized'
|
||||
|
||||
import { PagedResponse } from '../../models/PagedResponse'
|
||||
import { Plus, Trash2, Edit } from 'lucide-react'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
|
||||
interface FilterProps {
|
||||
columnId: string
|
||||
}
|
||||
|
||||
interface CellProps<T, K extends keyof T = keyof T> {
|
||||
columnId: string
|
||||
data: T
|
||||
value: T[K]
|
||||
}
|
||||
|
||||
export interface DataTableColumn<T, K extends keyof T = keyof T> {
|
||||
id: string
|
||||
accessorKey: K
|
||||
header: string
|
||||
filter: (
|
||||
props: FilterProps,
|
||||
onFilterChange: (filterId: string, columnId: string, filters: string) => void
|
||||
) => React.ReactNode
|
||||
cell: (props: CellProps<T, K>) => React.ReactNode
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
rawd?: PagedResponse<T>
|
||||
columns: DataTableColumn<T>[]
|
||||
maxRecordsPerPage?: number
|
||||
|
||||
idFields?: string[]
|
||||
|
||||
allowAddRow?: () => boolean
|
||||
onAddRow?: () => void
|
||||
allowEditRow?: (ids: Record<string, string>) =>boolean
|
||||
onEditRow?: (ids: Record<string, string>) => void
|
||||
allowDeleteRow?: (ids: Record<string, string>) =>boolean
|
||||
onDeleteRow?: (ids: Record<string, string>) => void
|
||||
|
||||
onFilterChange?: (filters: Record<string, string>) => void
|
||||
onPreviousPage?: (pageNumber: number) => void
|
||||
onNextPage?: (pageNumber: number) => void
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
const DEFAULT_ACTION_WIDTH = 80
|
||||
const DEFAULT_COL_WIDTH = 150
|
||||
const HEADER_ROWS = 2
|
||||
const ROW_HEIGHT = 40
|
||||
|
||||
const DataTable = <T extends Record<string, unknown>,>(props: DataTableProps<T>) => {
|
||||
const {
|
||||
rawd,
|
||||
columns,
|
||||
idFields = ['id'],
|
||||
|
||||
allowAddRow = () => false,
|
||||
onAddRow,
|
||||
allowEditRow = (_) => false,
|
||||
onEditRow,
|
||||
allowDeleteRow = (_) => false,
|
||||
onDeleteRow,
|
||||
|
||||
onFilterChange,
|
||||
onPreviousPage,
|
||||
onNextPage,
|
||||
colspan = 12,
|
||||
storageKey,
|
||||
} = props
|
||||
|
||||
const {
|
||||
items = [],
|
||||
pageNumber = 0,
|
||||
pageSize = 0,
|
||||
totalCount = 0,
|
||||
totalPages = 0,
|
||||
hasPreviousPage = false,
|
||||
hasNextPage = false,
|
||||
} = rawd || {}
|
||||
|
||||
const gridRef = useRef<MultiGrid>(null)
|
||||
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, Record<string, string>>>({})
|
||||
|
||||
const [colWidths, setColWidths] = useState<number[]>(() => {
|
||||
const defaultWidths = [DEFAULT_ACTION_WIDTH, ...columns.map(() => DEFAULT_COL_WIDTH)]
|
||||
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
// Check if parsed is an array and matches the expected length
|
||||
if (Array.isArray(parsed) && parsed.length === columns.length + 1) { // +1 for action column
|
||||
return parsed
|
||||
}
|
||||
else {
|
||||
// Remove invalid storage and fall back to default
|
||||
localStorage.removeItem(storageKey)
|
||||
return defaultWidths
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ignore and fall back to default
|
||||
return defaultWidths
|
||||
}
|
||||
}
|
||||
|
||||
// If no storage key or no valid stored widths, return default
|
||||
return defaultWidths
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (storageKey) {
|
||||
localStorage.setItem(storageKey, JSON.stringify(colWidths))
|
||||
}
|
||||
if (gridRef.current) {
|
||||
gridRef.current.recomputeGridSize()
|
||||
gridRef.current.forceUpdateGrids()
|
||||
}
|
||||
}, [colWidths, storageKey])
|
||||
|
||||
const debouncedOnFilterChange = useMemo(
|
||||
() => (onFilterChange ? debounce(onFilterChange, 500) : undefined),
|
||||
[onFilterChange]
|
||||
)
|
||||
|
||||
const handleFilterChange = (
|
||||
filterId: string,
|
||||
columnId: string,
|
||||
filters: string
|
||||
) => {
|
||||
setFilterValues((prev) => {
|
||||
const newValues = {
|
||||
...prev,
|
||||
[filterId]: {
|
||||
...prev[filterId],
|
||||
[columnId]: filters,
|
||||
},
|
||||
}
|
||||
|
||||
const linqQueries = Object.fromEntries(
|
||||
Object.entries(newValues).map(([fid, cols]) => {
|
||||
const q = Object.values(cols)
|
||||
.filter((v) => v)
|
||||
.map((v) => `(${v})`)
|
||||
.join(' && ')
|
||||
return [fid, q]
|
||||
})
|
||||
)
|
||||
|
||||
debouncedOnFilterChange?.(linqQueries)
|
||||
return newValues
|
||||
})
|
||||
}
|
||||
|
||||
const handlePreviousPage = () => onPreviousPage?.(pageNumber - 1)
|
||||
const handleNextPage = () => onNextPage?.(pageNumber + 1)
|
||||
|
||||
|
||||
const getRealIdsFromRow = (rowIndex: number) => {
|
||||
const row = items[rowIndex]
|
||||
const ids = Object.fromEntries(
|
||||
idFields.map((key) => [key, `${row[key]}`])
|
||||
)
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
const handleAddRow = () => onAddRow?.()
|
||||
const handleEditRow = (rowIndex: number) => {
|
||||
const ids = getRealIdsFromRow(rowIndex)
|
||||
|
||||
onEditRow?.(ids)
|
||||
}
|
||||
const handleDeleteRow = (rowIndex: number) => {
|
||||
const ids = getRealIdsFromRow(rowIndex)
|
||||
|
||||
onDeleteRow?.(ids)
|
||||
}
|
||||
|
||||
const handleAllowAddRow = () => {
|
||||
return allowAddRow?.()
|
||||
}
|
||||
|
||||
const handleAllowEditRow = (rowIndex: number) => {
|
||||
const ids = getRealIdsFromRow(rowIndex)
|
||||
return allowEditRow?.(ids)
|
||||
}
|
||||
|
||||
const handleAllowDeleteRow = (rowIndex: number) => {
|
||||
const ids = getRealIdsFromRow(rowIndex)
|
||||
return allowDeleteRow?.(ids)
|
||||
}
|
||||
|
||||
const handleRowClick = (idx: number) =>
|
||||
setSelectedRowIndex((prev) => (prev === idx ? null : idx))
|
||||
|
||||
const handleHeaderResize = (colIdx: number, startX: number, startWidth: number) => {
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const delta = e.clientX - startX
|
||||
setColWidths(prev => {
|
||||
const next = [...prev]
|
||||
next[colIdx] = Math.max(40, startWidth + delta)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
const cellRenderer = ({ columnIndex, key, rowIndex, style }: GridCellProps) => {
|
||||
const isActionCol = columnIndex === 0
|
||||
const col = columns[columnIndex - 1]
|
||||
|
||||
// classi statiche; solo width/height via style
|
||||
const commonClasses = [
|
||||
'box-border',
|
||||
'flex',
|
||||
'items-center',
|
||||
'px-2',
|
||||
'py-1',
|
||||
'border-b',
|
||||
'border-r',
|
||||
'border-gray-200',
|
||||
'overflow-hidden',
|
||||
'whitespace-nowrap',
|
||||
'truncate',
|
||||
rowIndex >= HEADER_ROWS ? 'cursor-pointer' : '',
|
||||
rowIndex >= HEADER_ROWS && selectedRowIndex === rowIndex - HEADER_ROWS ? 'bg-sky-100' : '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
// Header
|
||||
if (rowIndex === 0) {
|
||||
const allowAddRowResult = handleAllowAddRow()
|
||||
|
||||
if (isActionCol) {
|
||||
return (
|
||||
<div key={key} style={style} className={commonClasses}>
|
||||
<button
|
||||
onClick={handleAddRow}
|
||||
disabled={!allowAddRowResult}
|
||||
className={`p-1 ${allowAddRowResult
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
}`}>
|
||||
<Plus />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// il div esterno mantiene style/className, Resizable solo sul contenuto
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ ...style, userSelect: 'none' }}
|
||||
className={commonClasses}
|
||||
>
|
||||
<span>{col.header}</span>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: 8,
|
||||
cursor: 'col-resize',
|
||||
zIndex: 10,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
handleHeaderResize(columnIndex, e.clientX, colWidths[columnIndex])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter row
|
||||
if (rowIndex === 1) {
|
||||
return isActionCol ? (
|
||||
<div key={key} style={style} className={commonClasses} />
|
||||
) : (
|
||||
<div key={key} style={style} className={commonClasses}>
|
||||
{col.filter({ columnId: col.id }, handleFilterChange)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Data rows
|
||||
const dataIdx = rowIndex - HEADER_ROWS
|
||||
if (isActionCol) {
|
||||
const allowEditRowResult = handleAllowEditRow(dataIdx)
|
||||
const allowDeleteRowResult = handleAllowDeleteRow(dataIdx)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
className={commonClasses} onClick={() => handleRowClick(dataIdx)}>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleEditRow(dataIdx)
|
||||
}}
|
||||
disabled={!allowEditRowResult}
|
||||
className={`p-1 ${allowEditRowResult
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
}`}>
|
||||
<Edit />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleDeleteRow(dataIdx)
|
||||
}}
|
||||
disabled={!allowDeleteRowResult}
|
||||
className={`p-1 ${allowDeleteRowResult
|
||||
? 'cursor-pointer'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
}`}>
|
||||
<Trash2 />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const row = items[dataIdx]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
className={commonClasses}
|
||||
onClick={() => setSelectedRowIndex(dataIdx)}
|
||||
>
|
||||
{col.cell({
|
||||
columnId: col.id,
|
||||
data: row,
|
||||
value: row[col.accessorKey]
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Handler for MultiGrid scroll
|
||||
const handleGridScroll = ({
|
||||
scrollTop,
|
||||
clientHeight,
|
||||
scrollHeight,
|
||||
}: {
|
||||
scrollTop: number
|
||||
clientHeight: number
|
||||
scrollHeight: number
|
||||
}) => {
|
||||
// At bottom
|
||||
if (scrollTop + clientHeight >= scrollHeight - 2 && hasNextPage) {
|
||||
handleNextPage()
|
||||
}
|
||||
// At top
|
||||
if (scrollTop <= 2 && hasPreviousPage) {
|
||||
handlePreviousPage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`col-span-${colspan} flex flex-col h-full w-full`}>
|
||||
<div className={'flex-1'}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<MultiGrid
|
||||
ref={gridRef}
|
||||
cellRenderer={cellRenderer}
|
||||
columnCount={columns.length + 1}
|
||||
columnWidth={({ index }) => colWidths[index]}
|
||||
fixedColumnCount={1}
|
||||
fixedRowCount={HEADER_ROWS}
|
||||
height={height}
|
||||
rowCount={items.length + HEADER_ROWS}
|
||||
rowHeight={({ index }) => index === 1 ? ROW_HEIGHT * 2 : ROW_HEIGHT}
|
||||
width={width}
|
||||
onScroll={({ scrollTop, clientHeight, scrollHeight }) =>
|
||||
handleGridScroll({ scrollTop, clientHeight, scrollHeight })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div className={'mt-4 text-sm'}>
|
||||
<div className={'flex justify-end gap-4'}>
|
||||
<span>Page Size: {pageSize}</span>
|
||||
<span>Total Pages: {totalPages}</span>
|
||||
<span>Total Count: {totalCount}</span>
|
||||
</div>
|
||||
<div className={'flex items-center justify-between mt-2'}>
|
||||
<button
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
|
||||
aria-disabled={!hasPreviousPage}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>Page {pageNumber} of {totalPages}</span>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className={'px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 aria-disabled:opacity-50'}
|
||||
aria-disabled={!hasNextPage}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataTable }
|
||||
161
src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx
Normal file
161
src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { debounce } from 'lodash'
|
||||
import { useAppDispatch } from '../../redux/hooks'
|
||||
import { postData } from '../../axiosConfig'
|
||||
import { PagedRequest } from '../../models/PagedRequest'
|
||||
import { PagedResponse } from '../../models/PagedResponse'
|
||||
|
||||
interface FilterPropsBase {
|
||||
filterId?: string
|
||||
columnId: string
|
||||
accessorKey: string
|
||||
value?: FilterState
|
||||
disabled?: boolean
|
||||
onFilterChange?: (filterId: string, columnId: string, filters: string) => void
|
||||
}
|
||||
|
||||
interface NormalFilterProps extends FilterPropsBase {
|
||||
type: 'normal',
|
||||
}
|
||||
|
||||
interface RemoteFilterProps extends FilterPropsBase {
|
||||
type: 'remote',
|
||||
route: string
|
||||
}
|
||||
|
||||
type FilterProps = NormalFilterProps | RemoteFilterProps
|
||||
|
||||
interface FilterState {
|
||||
value: string,
|
||||
operator: string
|
||||
}
|
||||
|
||||
const DataTableFilter = <T extends { [key: string]: string }>(props: FilterProps) => {
|
||||
const {
|
||||
type,
|
||||
filterId = 'filters',
|
||||
columnId,
|
||||
accessorKey,
|
||||
value = {
|
||||
value: '',
|
||||
operator: 'contains'
|
||||
},
|
||||
disabled = false,
|
||||
onFilterChange
|
||||
} = props
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [filterState, setFilterState] = useState<FilterState>(value)
|
||||
|
||||
const debounceOnFilterChange = useMemo(() => {
|
||||
if (!onFilterChange) return
|
||||
|
||||
return debounce((route: string, filters: string) => {
|
||||
|
||||
postData<PagedRequest, PagedResponse<T>>(route, {
|
||||
pageSize: 100,
|
||||
filters
|
||||
}).then((response) => {
|
||||
if (!response) return
|
||||
|
||||
const linqQuery = response.items.map(item => `${columnId} == "${item['id']}"`).join(' || ')
|
||||
onFilterChange?.(filterId, columnId, linqQuery)
|
||||
|
||||
}).finally(() => {
|
||||
|
||||
})
|
||||
}, 500)
|
||||
|
||||
}, [filterId, columnId, dispatch, onFilterChange])
|
||||
|
||||
const handleFilterChange = (value: string, operator: string) => {
|
||||
setFilterState({
|
||||
value,
|
||||
operator
|
||||
})
|
||||
|
||||
if (value === '') {
|
||||
onFilterChange?.(filterId, columnId, '')
|
||||
return
|
||||
}
|
||||
|
||||
let linqQuery = ''
|
||||
|
||||
switch (operator) {
|
||||
case 'contains':
|
||||
linqQuery = `${accessorKey}.Contains("${value}")`
|
||||
break
|
||||
case 'startsWith':
|
||||
linqQuery = `${accessorKey}.StartsWith("${value}")`
|
||||
break
|
||||
case 'endsWith':
|
||||
linqQuery = `${accessorKey}.EndsWith("${value}")`
|
||||
break
|
||||
case '=':
|
||||
linqQuery = `${accessorKey} == "${value}"`
|
||||
break
|
||||
case '!=':
|
||||
linqQuery = `${accessorKey} != "${value}"`
|
||||
break
|
||||
case '>':
|
||||
linqQuery = `${accessorKey} > "${value}"`
|
||||
break
|
||||
case '<':
|
||||
linqQuery = `${accessorKey} < "${value}"`
|
||||
break
|
||||
case '>=':
|
||||
linqQuery = `${accessorKey} >= "${value}"`
|
||||
break
|
||||
case '<=':
|
||||
linqQuery = `${accessorKey} <= "${value}"`
|
||||
break
|
||||
default:
|
||||
linqQuery = `${accessorKey}.Contains("${value}")` // Default case handles using 'contains' as the safe fallback
|
||||
break
|
||||
}
|
||||
|
||||
if (type === 'normal') {
|
||||
onFilterChange?.(filterId, columnId, linqQuery)
|
||||
}
|
||||
|
||||
if (type === 'remote' && debounceOnFilterChange) {
|
||||
const { route } = props as RemoteFilterProps
|
||||
|
||||
debounceOnFilterChange(route, linqQuery)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col gap-2'}>
|
||||
<input
|
||||
type={'text'}
|
||||
placeholder={'Filter...'}
|
||||
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
|
||||
value={filterState.value}
|
||||
onChange={e => handleFilterChange(e.target.value, filterState.operator)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<select
|
||||
value={filterState.operator}
|
||||
onChange={e => handleFilterChange(filterState.value, e.target.value)}
|
||||
disabled={disabled}
|
||||
className={'w-full border border-outline/20 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:bg-gray-100 disabled:opacity-60'}
|
||||
>
|
||||
<option value={'contains'}>Contains</option>
|
||||
<option value={'startsWith'}>Starts With</option>
|
||||
<option value={'endsWith'}>Ends With</option>
|
||||
<option value={'='}>=</option>
|
||||
<option value={'!='}>!=</option>
|
||||
<option value={'>'}>{'>'}</option>
|
||||
<option value={'<'}>{'<'}</option>
|
||||
<option value={'>='}>{'>='}</option>
|
||||
<option value={'<='}>{'<='}</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableFilter
|
||||
}
|
||||
63
src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx
Normal file
63
src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getData } from '../../axiosConfig'
|
||||
import { useAppDispatch } from '../../redux/hooks'
|
||||
import { formatISODateString } from '../../functions'
|
||||
|
||||
interface NormalLabelProps {
|
||||
type: 'normal',
|
||||
value?: string,
|
||||
dataType?: 'string' | 'date'
|
||||
}
|
||||
|
||||
interface RemoteLabelProps {
|
||||
type: 'remote',
|
||||
route: string,
|
||||
columnId: string,
|
||||
accessorKey: string,
|
||||
}
|
||||
|
||||
type LabelProps = NormalLabelProps | RemoteLabelProps
|
||||
|
||||
const DataTableLabel = <T extends { [key: string]: never }>(props: LabelProps) => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [label, setLabel] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const { type } = props
|
||||
|
||||
if (type === 'normal') {
|
||||
const { value = '', dataType = 'string' } = props as NormalLabelProps
|
||||
|
||||
switch (dataType) {
|
||||
case 'date':
|
||||
setLabel(formatISODateString(value))
|
||||
break
|
||||
case 'string':
|
||||
default:
|
||||
setLabel(value)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'remote') {
|
||||
const { route, accessorKey } = props as RemoteLabelProps
|
||||
|
||||
getData<T>(route)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
|
||||
setLabel(response[accessorKey])
|
||||
}).finally(() => {
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
}, [props, dispatch])
|
||||
|
||||
return <p>{label}</p>
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableLabel
|
||||
}
|
||||
18
src/MaksIT.WebUI/src/components/DataTable/helpers.ts
Normal file
18
src/MaksIT.WebUI/src/components/DataTable/helpers.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { DataTableColumn } from './DataTable'
|
||||
|
||||
const createColumn = <T, K extends keyof T>(col: DataTableColumn<T, K>): DataTableColumn<T, K> => {
|
||||
return col
|
||||
}
|
||||
|
||||
/**
|
||||
* TypeScript cannot express "an array of DataTableColumn<T, K> for various K" without using any or a cast in the helper.
|
||||
* This is a known limitation and your use of a helper like createColumns is the best practical solution.
|
||||
* @param cols
|
||||
* @returns
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const createColumns = <T>(cols: DataTableColumn<T, any>[]) => {
|
||||
return cols as unknown as DataTableColumn<T, keyof T>[]
|
||||
}
|
||||
|
||||
export { createColumn, createColumns }
|
||||
29
src/MaksIT.WebUI/src/components/DataTable/index.ts
Normal file
29
src/MaksIT.WebUI/src/components/DataTable/index.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
DataTable,
|
||||
DataTableColumn
|
||||
} from './DataTable'
|
||||
|
||||
import {
|
||||
createColumn,
|
||||
createColumns
|
||||
} from './helpers'
|
||||
|
||||
import {
|
||||
DataTableLabel
|
||||
} from './DataTableLabel'
|
||||
|
||||
import {
|
||||
DataTableFilter
|
||||
} from './DataTableFilter'
|
||||
|
||||
export type {
|
||||
DataTableColumn
|
||||
}
|
||||
|
||||
export {
|
||||
DataTable,
|
||||
DataTableLabel,
|
||||
DataTableFilter,
|
||||
createColumn,
|
||||
createColumns
|
||||
}
|
||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormContainer.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormContainer.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface FormContainerProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FormContainer: FC<FormContainerProps> = (props) => {
|
||||
const {
|
||||
children
|
||||
} = props
|
||||
|
||||
return <div className={'grid grid-rows-[auto_1fr_auto] h-full gap-0'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
export {
|
||||
FormContainer
|
||||
}
|
||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormContent.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormContent.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface FormContentProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FormContent: FC<FormContentProps> = (props) => {
|
||||
const {
|
||||
children
|
||||
} = props
|
||||
|
||||
return <div className={'bg-gray-100 w-full h-full p-4 overflow-y-auto'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
export {
|
||||
FormContent
|
||||
}
|
||||
31
src/MaksIT.WebUI/src/components/FormLayout/FormFooter.tsx
Normal file
31
src/MaksIT.WebUI/src/components/FormLayout/FormFooter.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
|
||||
interface FormFooterProps {
|
||||
children?: ReactNode,
|
||||
leftChildren?: ReactNode,
|
||||
rightChildren?: ReactNode
|
||||
}
|
||||
|
||||
const FormFooter: FC<FormFooterProps> = (props) => {
|
||||
|
||||
const {
|
||||
children,
|
||||
leftChildren,
|
||||
rightChildren
|
||||
} = props
|
||||
|
||||
return <div className={'bg-gray-200 p-4 h-14 flex justify-between items-center'}>
|
||||
{children ?? <>
|
||||
<div className={'flex space-x-4'}>{leftChildren}</div>
|
||||
<div className={'flex space-x-4'}>{rightChildren}</div>
|
||||
</>}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
FormFooter
|
||||
}
|
||||
19
src/MaksIT.WebUI/src/components/FormLayout/FormHeader.tsx
Normal file
19
src/MaksIT.WebUI/src/components/FormLayout/FormHeader.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface FormHeaderProps {
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FormHeader: FC<FormHeaderProps> = (props) => {
|
||||
const {
|
||||
children
|
||||
} = props
|
||||
|
||||
return <h1 className={'bg-gray-200 p-4 h-14 text-2xl font-bold'}>
|
||||
{children}
|
||||
</h1>
|
||||
}
|
||||
|
||||
export {
|
||||
FormHeader
|
||||
}
|
||||
11
src/MaksIT.WebUI/src/components/FormLayout/index.ts
Normal file
11
src/MaksIT.WebUI/src/components/FormLayout/index.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { FormContainer } from './FormContainer'
|
||||
import { FormHeader } from './FormHeader'
|
||||
import { FormContent } from './FormContent'
|
||||
import { FormFooter } from './FormFooter'
|
||||
|
||||
export {
|
||||
FormContainer,
|
||||
FormHeader,
|
||||
FormContent,
|
||||
FormFooter
|
||||
}
|
||||
17
src/MaksIT.WebUI/src/components/Layout/Container.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/Container.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface ContentProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Container: FC<ContentProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <div className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
export {
|
||||
Container
|
||||
}
|
||||
17
src/MaksIT.WebUI/src/components/Layout/Content.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/Content.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface ContentProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Content: FC<ContentProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <main className={'bg-gray-100 h-full w-full overflow-y-auto'}>
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
|
||||
export {
|
||||
Content
|
||||
}
|
||||
16
src/MaksIT.WebUI/src/components/Layout/Footer.tsx
Normal file
16
src/MaksIT.WebUI/src/components/Layout/Footer.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface FooterProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = (props) => {
|
||||
const { children } = props
|
||||
return <footer className={'bg-blue-500 text-white p-4 h-14 flex items-center justify-center'}>
|
||||
{children}
|
||||
</footer>
|
||||
}
|
||||
|
||||
export {
|
||||
Footer
|
||||
}
|
||||
19
src/MaksIT.WebUI/src/components/Layout/Header.tsx
Normal file
19
src/MaksIT.WebUI/src/components/Layout/Header.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface HeaderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Header: FC<HeaderProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <header>
|
||||
<div className={'bg-blue-500 text-white p-4 h-14 flex justify-between items-center'}>
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
export {
|
||||
Header
|
||||
}
|
||||
17
src/MaksIT.WebUI/src/components/Layout/MainContainer.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/MainContainer.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const MainContainer: FC<ContainerProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <div className={'grid grid-cols-[250px_1fr] h-screen w-screen'}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
export {
|
||||
MainContainer
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface ContainerProps {
|
||||
headerChildren?: ReactNode
|
||||
children: ReactNode
|
||||
footerChildren?: ReactNode
|
||||
}
|
||||
|
||||
const Container: FC<ContainerProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <aside className={'grid grid-rows-[auto_1fr_auto] h-screen'}>
|
||||
{children}
|
||||
</aside>
|
||||
}
|
||||
|
||||
export {
|
||||
Container
|
||||
}
|
||||
17
src/MaksIT.WebUI/src/components/Layout/SideMenu/Content.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Layout/SideMenu/Content.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
interface ContentProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Content: FC<ContentProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <main className={'bg-gray-200 h-full p-4 overflow-y-auto border-r border-blue-500'}>
|
||||
{children}
|
||||
</main>
|
||||
}
|
||||
|
||||
export {
|
||||
Content
|
||||
}
|
||||
16
src/MaksIT.WebUI/src/components/Layout/SideMenu/Footer.tsx
Normal file
16
src/MaksIT.WebUI/src/components/Layout/SideMenu/Footer.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface FooterProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Footer: FC<FooterProps> = (props) => {
|
||||
const { children } = props
|
||||
return <footer className={'bg-blue-500 text-white p-4 h-14'}>
|
||||
{children}
|
||||
</footer>
|
||||
}
|
||||
|
||||
export {
|
||||
Footer
|
||||
}
|
||||
19
src/MaksIT.WebUI/src/components/Layout/SideMenu/Header.tsx
Normal file
19
src/MaksIT.WebUI/src/components/Layout/SideMenu/Header.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface HeaderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Header: FC<HeaderProps> = (props) => {
|
||||
const { children } = props
|
||||
|
||||
return <header>
|
||||
<div className={'bg-blue-500 text-white p-4 h-14'}>
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
|
||||
export {
|
||||
Header
|
||||
}
|
||||
32
src/MaksIT.WebUI/src/components/Layout/SideMenu/index.tsx
Normal file
32
src/MaksIT.WebUI/src/components/Layout/SideMenu/index.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { Container } from '../Container'
|
||||
import { Header } from './Header'
|
||||
import { Content } from './Content'
|
||||
import { Footer } from './Footer'
|
||||
|
||||
|
||||
export interface SideMenuProps {
|
||||
headerChildren?: ReactNode
|
||||
children: ReactNode
|
||||
footerChildren?: ReactNode
|
||||
}
|
||||
|
||||
const SideMenu: FC<SideMenuProps> = (props) => {
|
||||
const { headerChildren, children, footerChildren } = props
|
||||
|
||||
return <Container>
|
||||
<Header>
|
||||
{headerChildren}
|
||||
</Header>
|
||||
<Content>
|
||||
{children}
|
||||
</Content>
|
||||
<Footer>
|
||||
{footerChildren}
|
||||
</Footer>
|
||||
</Container>
|
||||
}
|
||||
|
||||
export {
|
||||
SideMenu
|
||||
}
|
||||
40
src/MaksIT.WebUI/src/components/Layout/index.tsx
Normal file
40
src/MaksIT.WebUI/src/components/Layout/index.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { FC } from 'react'
|
||||
import { MainContainer } from './MainContainer'
|
||||
import { SideMenu, SideMenuProps } from './SideMenu'
|
||||
import { Container } from './Container'
|
||||
import { Header, HeaderProps } from './Header'
|
||||
import { Footer, FooterProps } from './Footer'
|
||||
import { Content } from './Content'
|
||||
|
||||
interface LayoutProps {
|
||||
sideMenu: SideMenuProps
|
||||
header: HeaderProps
|
||||
children: React.ReactNode
|
||||
footer: FooterProps
|
||||
}
|
||||
|
||||
const Layout: FC<LayoutProps> = (props) => {
|
||||
const { sideMenu, header, children, footer } = props
|
||||
|
||||
return <MainContainer>
|
||||
<SideMenu
|
||||
headerChildren={sideMenu.headerChildren}
|
||||
footerChildren={sideMenu.footerChildren}
|
||||
>
|
||||
{sideMenu.children}
|
||||
</SideMenu>
|
||||
<Container>
|
||||
<Header>
|
||||
{header.children}
|
||||
</Header>
|
||||
<Content>
|
||||
{children}
|
||||
</Content>
|
||||
<Footer>{footer.children}</Footer>
|
||||
</Container>
|
||||
</MainContainer>
|
||||
}
|
||||
|
||||
export {
|
||||
Layout
|
||||
}
|
||||
84
src/MaksIT.WebUI/src/components/LazyLoadTable.tsx
Normal file
84
src/MaksIT.WebUI/src/components/LazyLoadTable.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface LazyLoadTableColumnProps {
|
||||
key: string
|
||||
title: string
|
||||
dataIndex: string
|
||||
renderColumn?: (value: any) => React.ReactNode
|
||||
}
|
||||
|
||||
interface LazyLoadTableProps {
|
||||
data: any[]
|
||||
columns: LazyLoadTableColumnProps[]
|
||||
loadMore: () => void
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
}
|
||||
|
||||
const LazyLoadTable: FC<LazyLoadTableProps> = (props) => {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
loadMore,
|
||||
colspan = 6
|
||||
} = props
|
||||
|
||||
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
|
||||
const observerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
loadMore()
|
||||
}
|
||||
},
|
||||
{ threshold: 1.0 }
|
||||
)
|
||||
|
||||
if (observerRef.current) {
|
||||
observer.observe(observerRef.current)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observer.unobserve(observerRef.current)
|
||||
}
|
||||
}
|
||||
}, [loadMore])
|
||||
|
||||
const handleRowClick = (index: number) => {
|
||||
setSelectedRowIndex(selectedRowIndex === index ? null : index)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`col-span-${colspan}`}>
|
||||
<table className={'w-full border-collapse'}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th key={index} className={'bg-gray-50 text-left py-2 px-4 border-b'}>{column.title}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-gray-100 ${selectedRowIndex === index ? 'bg-blue-100' : ''} transition-colors`}
|
||||
onClick={() => handleRowClick(index)}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td className={'py-2 px-4 border-b'} key={colIndex}>
|
||||
{column.renderColumn ? column.renderColumn(row[column.dataIndex]) : row[column.dataIndex]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={observerRef} className={'text-center py-4'}>Loading more...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { LazyLoadTable }
|
||||
17
src/MaksIT.WebUI/src/components/Loader.tsx
Normal file
17
src/MaksIT.WebUI/src/components/Loader.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { useAppSelector } from '../redux/hooks'
|
||||
import { RootState } from '../redux/store'
|
||||
|
||||
|
||||
const Loader = () => {
|
||||
const loading = useAppSelector((state: RootState) => state.loader.loading)
|
||||
|
||||
if (!loading) return null
|
||||
|
||||
return (
|
||||
<div className={'fixed inset-0 flex items-center justify-center bg-gray-800/75 z-50'}>
|
||||
<div className={'ease-linear rounded-full border-8 border-t-8 border-gray-200 h-32 w-32 animate-spin'} style={{ borderTopColor: '#3498db' }}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Loader }
|
||||
139
src/MaksIT.WebUI/src/components/LoginScreen.tsx
Normal file
139
src/MaksIT.WebUI/src/components/LoginScreen.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { LoginRequest, LoginRequestSchema } from '../models/identity/login/LoginRequest'
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||
import { login } from '../redux/slices/identitySlice'
|
||||
import { useFormState } from '../hooks/useFormState'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ButtonComponent, CheckBoxComponent, TextBoxComponent } from './editors'
|
||||
|
||||
const LoginScreen: React.FC = () => {
|
||||
const [use2FA, setUse2FA] = useState(false)
|
||||
const [use2FARecovery, setUse2FARecovery] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const { identity } = useAppSelector((state) => state.identity)
|
||||
|
||||
const {
|
||||
formState,
|
||||
errors,
|
||||
formIsValid,
|
||||
handleInputChange
|
||||
} = useFormState<LoginRequest>({
|
||||
initialState: { username: '', password: '' },
|
||||
validationSchema: LoginRequestSchema,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!identity || new Date(identity.refreshTokenExpiresAt) < new Date()) {
|
||||
navigate('/login', { replace: true })
|
||||
} else {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [identity, navigate])
|
||||
|
||||
const handleUse2FA = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUse2FA(e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
setUse2FARecovery(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (!formIsValid) return
|
||||
|
||||
if (formState.twoFactorCode === '') delete formState.twoFactorCode
|
||||
if (formState.twoFactorRecoveryCode === '') delete formState.twoFactorRecoveryCode
|
||||
|
||||
dispatch(login(formState))
|
||||
}
|
||||
|
||||
// Utility classes
|
||||
const inputClasses =
|
||||
'block w-full rounded-md border border-gray-300 p-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200'
|
||||
const checkboxClasses =
|
||||
'h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-200'
|
||||
const buttonPrimaryClasses =
|
||||
'w-full py-2 px-4 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-semibold'
|
||||
|
||||
return (
|
||||
<div className={'flex items-center justify-center min-h-screen bg-gray-100'}>
|
||||
<div className={'w-full max-w-md bg-white rounded-lg shadow-md p-8 space-y-6'}>
|
||||
{/* Logo */}
|
||||
<div className={'flex justify-center'}>
|
||||
<img src={'/logo.png'} alt={'Logo'} className={'h-12 w-auto'} />
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className={'space-y-4'}>
|
||||
<div className={'space-y-4'}>
|
||||
<TextBoxComponent
|
||||
label={'Email'}
|
||||
placeholder={'Email...'}
|
||||
value={formState.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
errorText={errors.username}
|
||||
/>
|
||||
|
||||
<TextBoxComponent
|
||||
label={'Password'}
|
||||
placeholder={'Password...'}
|
||||
type={'password'}
|
||||
value={formState.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
errorText={errors.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2FA Options */}
|
||||
<div className={'flex items-center gap-4'}>
|
||||
<CheckBoxComponent
|
||||
label={'Use 2FA'}
|
||||
value={use2FA}
|
||||
onChange={handleUse2FA}
|
||||
/>
|
||||
{use2FA && (
|
||||
<CheckBoxComponent
|
||||
label={'Use 2FA Recovery'}
|
||||
value={use2FARecovery}
|
||||
onChange={(e) => setUse2FARecovery(e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2FA Inputs */}
|
||||
{use2FA && (
|
||||
<div className={'space-y-4'}>
|
||||
{use2FARecovery ? (
|
||||
<TextBoxComponent
|
||||
label={'2FA Recovery Code'}
|
||||
placeholder={'Recovery code...'}
|
||||
value={formState.twoFactorRecoveryCode}
|
||||
onChange={(e) => handleInputChange('twoFactorRecoveryCode', e.target.value)}
|
||||
errorText={errors.twoFactorRecoveryCode}
|
||||
/>
|
||||
) : (
|
||||
<TextBoxComponent
|
||||
label={'2FA Code'}
|
||||
placeholder={'Authentication code...'}
|
||||
value={formState.twoFactorCode}
|
||||
onChange={(e) => handleInputChange('twoFactorCode', e.target.value)}
|
||||
errorText={errors.twoFactorCode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<ButtonComponent
|
||||
label={'Sign in'}
|
||||
buttonHierarchy={'primary'}
|
||||
onClick={handleLogin}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { LoginScreen }
|
||||
56
src/MaksIT.WebUI/src/components/Offcanvas.tsx
Normal file
56
src/MaksIT.WebUI/src/components/Offcanvas.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { FC, ReactNode, useCallback, useEffect } from 'react'
|
||||
|
||||
export interface OffcanvasProps {
|
||||
children: ReactNode
|
||||
isOpen?: boolean
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
}
|
||||
|
||||
const Offcanvas: FC<OffcanvasProps> = (props) => {
|
||||
const {
|
||||
children,
|
||||
isOpen = false,
|
||||
onOpen,
|
||||
onClose,
|
||||
colspan = 6
|
||||
} = props
|
||||
|
||||
const handleOnOpen = useCallback(() => {
|
||||
onOpen?.()
|
||||
}, [onOpen])
|
||||
|
||||
const handleOnClose = useCallback(() => {
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) handleOnOpen()
|
||||
else handleOnClose()
|
||||
}, [isOpen, handleOnOpen, handleOnClose])
|
||||
|
||||
const leftSpan = 12 - colspan
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'fixed inset-0 h-screen w-screen',
|
||||
'bg-black/20 backdrop-blur-md',
|
||||
'z-40 transition-opacity duration-300 ease-in-out',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={'grid grid-cols-12 h-full w-full'}>
|
||||
{/* colonna di offset */}
|
||||
<div className={`col-span-${leftSpan}`} />
|
||||
{/* area principale */}
|
||||
<div className={`col-span-${colspan} min-h-0`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Offcanvas }
|
||||
13
src/MaksIT.WebUI/src/components/Toast/addToast.ts
Normal file
13
src/MaksIT.WebUI/src/components/Toast/addToast.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// Define the types for the toast
|
||||
interface AddToastProps {
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export const addToast = (message: string, type: 'info' | 'success' | 'warning' | 'error', duration?: number): void => {
|
||||
const event = new CustomEvent<AddToastProps>('add-toast', {
|
||||
detail: { message, type, duration },
|
||||
})
|
||||
window.dispatchEvent(event)
|
||||
}
|
||||
70
src/MaksIT.WebUI/src/components/Toast/index.tsx
Normal file
70
src/MaksIT.WebUI/src/components/Toast/index.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, FC } from 'react'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// Define types for a toast
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const Toast: FC = () => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const handleAddToast = (event: CustomEvent<Toast>) => {
|
||||
const { message, type, duration } = event.detail
|
||||
|
||||
// Add the new toast
|
||||
const id = uuidv4()
|
||||
setToasts((prev) => [...prev, { id, message, type, duration }])
|
||||
|
||||
// Auto-remove if a duration is specified
|
||||
if (duration) {
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}, duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for the custom event
|
||||
window.addEventListener('add-toast', handleAddToast as EventListener)
|
||||
|
||||
return () => {
|
||||
// Cleanup event listener on component unmount
|
||||
window.removeEventListener('add-toast', handleAddToast as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remove toast manually
|
||||
const handleClose = (id: string) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'fixed bottom-16 right-4 flex flex-col gap-2 z-50'}>
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`relative flex items-center justify-between gap-3 px-4 py-3 pr-10 rounded-md shadow-md text-white
|
||||
${toast.type === 'success' ? 'bg-green-500' : ''}
|
||||
${toast.type === 'error' ? 'bg-red-500' : ''}
|
||||
${toast.type === 'warning' ? 'bg-yellow-500' : ''}
|
||||
${toast.type === 'info' ? 'bg-blue-500' : ''}
|
||||
`}
|
||||
>
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
onClick={() => handleClose(toast.id)}
|
||||
className={'absolute top-2 right-2 text-xl font-bold text-white hover:opacity-75'}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Toast
|
||||
}
|
||||
26
src/MaksIT.WebUI/src/components/UserButton.tsx
Normal file
26
src/MaksIT.WebUI/src/components/UserButton.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||
import { setShowUserOffcanvas } from '../redux/slices/identitySlice'
|
||||
|
||||
const UserButton = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
//const { identity } = useAppSelector(state => state.identity)
|
||||
|
||||
const identity = {
|
||||
username: 'JohnDoe',
|
||||
isGlobalAdmin: true
|
||||
}
|
||||
|
||||
if (!identity) return <></>
|
||||
|
||||
return <button
|
||||
className={'bg-white text-blue-500 px-2 py-1 rounded'}
|
||||
onClick={() => {
|
||||
dispatch(setShowUserOffcanvas())
|
||||
}}>
|
||||
{`${identity.username} ${identity.isGlobalAdmin ? '(Global Admin)' : ''}`.trim()}
|
||||
</button>
|
||||
}
|
||||
|
||||
export {
|
||||
UserButton
|
||||
}
|
||||
48
src/MaksIT.WebUI/src/components/UserOffcanvas.tsx
Normal file
48
src/MaksIT.WebUI/src/components/UserOffcanvas.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useAppDispatch, useAppSelector } from '../redux/hooks'
|
||||
import { logout, setHideUserOffcanvas } from '../redux/slices/identitySlice'
|
||||
import { ButtonComponent } from './editors'
|
||||
import { FormContainer, FormContent, FormHeader } from './FormLayout'
|
||||
import { Offcanvas } from './Offcanvas'
|
||||
|
||||
const UserOffcanvas = () => {
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const { identity, showUserOffcanvas } = useAppSelector(state => state.identity)
|
||||
|
||||
return (
|
||||
<Offcanvas isOpen={showUserOffcanvas} colspan={4}>
|
||||
<FormContainer>
|
||||
<FormHeader>{identity?.username}</FormHeader>
|
||||
<FormContent>
|
||||
<div className={'flex flex-col justify-between h-full'}>
|
||||
<div className={'flex flex-col items-center gap-3'}>
|
||||
<ButtonComponent
|
||||
label={'Edit User'}
|
||||
route={`/user/${identity?.userId}`}
|
||||
onClick={() => {
|
||||
dispatch(setHideUserOffcanvas())
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex flex-col items-center gap-3'}>
|
||||
<ButtonComponent
|
||||
label={'Back'}
|
||||
buttonHierarchy={'secondary'}
|
||||
onClick={() => dispatch(setHideUserOffcanvas())}
|
||||
/>
|
||||
<ButtonComponent
|
||||
label={'Logout'}
|
||||
buttonHierarchy={'error'}
|
||||
onClick={() => dispatch(logout(false))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormContent>
|
||||
</FormContainer>
|
||||
</Offcanvas>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
UserOffcanvas
|
||||
}
|
||||
79
src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx
Normal file
79
src/MaksIT.WebUI/src/components/editors/ButtonComponent.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
interface ConditionalButtonProps {
|
||||
label: string;
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
route?: string;
|
||||
buttonHierarchy?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ButtonComponent: React.FC<ConditionalButtonProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
colspan,
|
||||
route,
|
||||
buttonHierarchy,
|
||||
onClick,
|
||||
disabled = false
|
||||
} = props
|
||||
|
||||
const handleClick = (e?: React.MouseEvent) => {
|
||||
if (disabled) {
|
||||
e?.preventDefault()
|
||||
return
|
||||
}
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
let buttonClass = ''
|
||||
switch (buttonHierarchy) {
|
||||
case 'primary':
|
||||
buttonClass = 'bg-blue-500 text-white'
|
||||
break
|
||||
case 'secondary':
|
||||
buttonClass = 'bg-gray-500 text-white'
|
||||
break
|
||||
case 'success':
|
||||
buttonClass = 'bg-green-500 text-white'
|
||||
break
|
||||
case 'warning':
|
||||
buttonClass = 'bg-yellow-500 text-white'
|
||||
break
|
||||
case 'error':
|
||||
buttonClass = 'bg-red-500 text-white'
|
||||
break
|
||||
default:
|
||||
buttonClass = 'bg-blue-500 text-white'
|
||||
break
|
||||
}
|
||||
|
||||
const disabledClass = disabled ? 'opacity-50 cursor-default' : 'cursor-pointer'
|
||||
|
||||
return route
|
||||
? (
|
||||
<Link
|
||||
to={route}
|
||||
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} text-center ${disabledClass}`}
|
||||
onClick={handleClick}
|
||||
tabIndex={disabled ? -1 : undefined}
|
||||
aria-disabled={disabled}
|
||||
style={disabled ? { pointerEvents: 'none' } : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className={`${buttonClass} px-4 py-2 rounded ${colspan ? `col-span-${colspan}` : 'w-full'} ${disabledClass}`}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export { ButtonComponent }
|
||||
@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface CheckBoxComponentProps {
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
label: string;
|
||||
value: boolean;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
errorText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CheckBoxComponent: React.FC<CheckBoxComponentProps> = (props) => {
|
||||
|
||||
const {
|
||||
colspan = 6,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
errorText,
|
||||
disabled = false
|
||||
} = props
|
||||
|
||||
const prevValue = useRef<boolean>(value)
|
||||
|
||||
useEffect(() => {
|
||||
prevValue.current = value
|
||||
}, [value])
|
||||
|
||||
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (prevValue.current === e.target.checked)
|
||||
return
|
||||
|
||||
prevValue.current = e.target.checked
|
||||
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mb-4 col-span-${colspan}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||
<input
|
||||
type={'checkbox'}
|
||||
checked={value}
|
||||
onChange={handleOnChange}
|
||||
className={`mr-2 leading-tight ${errorText ? 'border-red-500' : ''}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
{errorText && (
|
||||
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
CheckBoxComponent
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
import { ChangeEvent, FC, useState, useEffect, useRef } from 'react'
|
||||
import { parseISO, formatISO, format, getDaysInMonth, addMonths, subMonths } from 'date-fns'
|
||||
import { ButtonComponent } from './ButtonComponent'
|
||||
import { TextBoxComponent } from './TextBoxComponent'
|
||||
import { CircleX } from 'lucide-react'
|
||||
|
||||
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
|
||||
|
||||
interface DateTimePickerComponentProps {
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
label: string
|
||||
value?: string
|
||||
onChange?: (isoString?: string) => void
|
||||
errorText?: string
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const DateTimePickerComponent: FC<DateTimePickerComponentProps> = ({
|
||||
colspan = 6,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
errorText,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const prevValueRef = useRef<string | undefined>(value)
|
||||
const parsedValue = value ? parseISO(value) : undefined
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [currentViewDate, setCurrentViewDate] = useState<Date>(parsedValue || new Date())
|
||||
const [tempDate, setTempDate] = useState<Date>(parsedValue || new Date())
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== prevValueRef.current) {
|
||||
const newDate = parsedValue || new Date()
|
||||
setCurrentViewDate(newDate)
|
||||
setTempDate(newDate)
|
||||
prevValueRef.current = value
|
||||
}
|
||||
}, [value, parsedValue])
|
||||
|
||||
const formatForDisplay = (date: Date) => format(date, DISPLAY_FORMAT)
|
||||
|
||||
const daysCount = getDaysInMonth(currentViewDate)
|
||||
const daysArray = Array.from({ length: daysCount }, (_, i) => i + 1)
|
||||
|
||||
const handlePrevMonth = () => setCurrentViewDate((prev) => subMonths(prev, 1))
|
||||
const handleNextMonth = () => setCurrentViewDate((prev) => addMonths(prev, 1))
|
||||
|
||||
const handleDayClick = (day: number) => {
|
||||
const newDate = new Date(tempDate)
|
||||
newDate.setFullYear(currentViewDate.getFullYear(), currentViewDate.getMonth(), day)
|
||||
setTempDate(newDate)
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const [hours, minutes] = e.target.value.split(':').map(Number)
|
||||
const newDate = new Date(tempDate)
|
||||
newDate.setHours(hours, minutes, 0, 0)
|
||||
setTempDate(newDate)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (prevValueRef.current !== undefined) {
|
||||
onChange?.(undefined)
|
||||
prevValueRef.current = undefined
|
||||
}
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const isoString = formatISO(tempDate, { representation: 'complete' })
|
||||
if (isoString !== prevValueRef.current) {
|
||||
onChange?.(isoString)
|
||||
prevValueRef.current = isoString
|
||||
}
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
const actionButtons = () => {
|
||||
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||
return [
|
||||
!!value && !readOnly && (
|
||||
<button
|
||||
key={'clear'}
|
||||
type={'button'}
|
||||
onClick={handleClear}
|
||||
className={className}
|
||||
tabIndex={-1}
|
||||
aria-label={'Clear'}
|
||||
>
|
||||
<CircleX />
|
||||
</button>
|
||||
),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDropdown) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showDropdown])
|
||||
|
||||
return (
|
||||
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`} ref={dropdownRef}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||
<div className={'relative'}>
|
||||
<input
|
||||
type={'text'}
|
||||
value={value ? formatForDisplay(parsedValue!) : ''}
|
||||
onFocus={() => !readOnly && !disabled && setShowDropdown(true)}
|
||||
readOnly
|
||||
placeholder={placeholder}
|
||||
className={`shadow appearance-none border rounded w-full px-3 py-2 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||
errorText ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Fixed Action Buttons */}
|
||||
<div className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}>
|
||||
{actionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDropdown && !readOnly && !disabled && (
|
||||
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
|
||||
<div className={'flex justify-between items-center px-3 py-2'}>
|
||||
<button onClick={handlePrevMonth} type={'button'}>
|
||||
<
|
||||
</button>
|
||||
<span>{format(currentViewDate, 'MMMM yyyy')}</span>
|
||||
<button onClick={handleNextMonth} type={'button'}>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div className={'grid grid-cols-7 gap-1 px-3 py-2'}>
|
||||
{daysArray.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`p-2 cursor-pointer text-center ${
|
||||
tempDate.getDate() === day &&
|
||||
tempDate.getMonth() === currentViewDate.getMonth() &&
|
||||
tempDate.getFullYear() === currentViewDate.getFullYear()
|
||||
? 'bg-blue-500 text-white rounded'
|
||||
: 'hover:bg-gray-200 rounded'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={'px-3 py-2'}>
|
||||
<TextBoxComponent
|
||||
label={'Time'}
|
||||
type={'time'}
|
||||
value={format(tempDate, 'HH:mm')}
|
||||
onChange={handleTimeChange}
|
||||
placeholder={'HH:MM'}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className={'px-3 py-2 gap-2 flex justify-between'}>
|
||||
<ButtonComponent label={'Clear'} buttonHierarchy={'secondary'} onClick={handleClear} />
|
||||
<ButtonComponent label={'Confirm'} buttonHierarchy={'primary'} onClick={handleConfirm} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DateTimePickerComponent }
|
||||
106
src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx
Normal file
106
src/MaksIT.WebUI/src/components/editors/DualListboxComponent.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface DualListboxComponentProps {
|
||||
label?: string;
|
||||
availableItemsLabel?: string;
|
||||
selectedItemsLabel?: string;
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
|
||||
idFieldName?: string;
|
||||
availableItems: string[];
|
||||
selectedItems: string[];
|
||||
onChange: (selectedItems: string[]) => void;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
const DualListboxComponent: React.FC<DualListboxComponentProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
availableItemsLabel = 'Available Items',
|
||||
selectedItemsLabel = 'Selected Items',
|
||||
colspan = 6,
|
||||
availableItems,
|
||||
selectedItems,
|
||||
onChange,
|
||||
errorText
|
||||
} = props
|
||||
|
||||
const [available, setAvailable] = useState<string[]>(availableItems)
|
||||
const [selected, setSelected] = useState<string[]>(selectedItems)
|
||||
|
||||
const moveToSelected = () => {
|
||||
const movedItems = available.filter(item => selected.includes(item))
|
||||
setAvailable(available.filter(item => !movedItems.includes(item)))
|
||||
setSelected([...selected, ...movedItems])
|
||||
onChange([...selected, ...movedItems])
|
||||
}
|
||||
|
||||
const moveToAvailable = () => {
|
||||
const movedItems = selected.filter(item => !available.includes(item))
|
||||
setSelected(selected.filter(item => !movedItems.includes(item)))
|
||||
setAvailable([...available, ...movedItems])
|
||||
onChange(selected.filter(item => !movedItems.includes(item)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`col-span-${colspan}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||
{label}
|
||||
</label>
|
||||
<div className={'flex justify-center items-center gap-4 w-full h-full'}>
|
||||
<div className={'flex flex-col'}>
|
||||
<h3>{availableItemsLabel}</h3>
|
||||
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||
{available.map(item => (
|
||||
<li
|
||||
key={item}
|
||||
className={'cursor-pointer hover:bg-gray-200'}
|
||||
onClick={() => setAvailable(available.filter(i => i !== item))}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={'flex flex-col gap-2'}>
|
||||
<button
|
||||
onClick={moveToSelected}
|
||||
className={'border px-4 py-2 bg-blue-500 text-white hover:bg-blue-600'}
|
||||
>
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
onClick={moveToAvailable}
|
||||
className={'border px-4 py-2 bg-red-500 text-white hover:bg-red-600'}
|
||||
>
|
||||
<
|
||||
</button>
|
||||
</div>
|
||||
<div className={'flex flex-col'}>
|
||||
<h3>{selectedItemsLabel}</h3>
|
||||
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||
{selected.map(item => (
|
||||
<li
|
||||
key={item}
|
||||
className={'cursor-pointer hover:bg-gray-200'}
|
||||
onClick={() => setSelected(selected.filter(i => i !== item))}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{errorText && (
|
||||
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DualListboxComponent
|
||||
}
|
||||
147
src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx
Normal file
147
src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { ButtonComponent } from './ButtonComponent'
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
label?: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
multiple?: boolean
|
||||
onChange?: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
label = 'Select files',
|
||||
colspan = 6,
|
||||
multiple = true,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const [popupPos, setPopupPos] = useState<{x: number, y: number}>({x: 0, y: 0})
|
||||
const popupRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus popup when it opens
|
||||
React.useEffect(() => {
|
||||
if (showPopup && popupRef.current) {
|
||||
popupRef.current.focus()
|
||||
}
|
||||
}, [showPopup])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : []
|
||||
setSelectedFiles(files)
|
||||
onChange?.(files)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedFiles([])
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
onChange?.([])
|
||||
}
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
if (!disabled) inputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-4 gap-2 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
{/* File input (hidden) */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={'file'}
|
||||
multiple={multiple}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Files counter with hover popup */}
|
||||
<div
|
||||
className={'col-span-1 flex items-center justify-center relative'}
|
||||
onMouseEnter={e => {
|
||||
setShowPopup(true)
|
||||
if (!showPopup) {
|
||||
setPopupPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
// Only close if not moving into popup
|
||||
if (!popupRef.current || !popupRef.current.contains(e.relatedTarget as Node)) {
|
||||
setShowPopup(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
|
||||
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{showPopup && selectedFiles.length > 0 && (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
left: popupPos.x + 2,
|
||||
top: popupPos.y + 2,
|
||||
maxWidth: '400px',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: '120px',
|
||||
pointerEvents: 'auto',
|
||||
outline: 'none',
|
||||
}}
|
||||
onBlur={e => {
|
||||
// Only close if focus moves outside popup and counter
|
||||
if (!e.relatedTarget || (!popupRef.current?.contains(e.relatedTarget as Node) && !(e.relatedTarget as HTMLElement)?.closest('.col-span-1'))) {
|
||||
setShowPopup(false)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
// Only close if not moving back to counter
|
||||
const parent = (e.relatedTarget as HTMLElement)?.closest('.col-span-1')
|
||||
if (!parent) setShowPopup(false)
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') setShowPopup(false)
|
||||
}}
|
||||
onFocus={() => {}}
|
||||
>
|
||||
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
|
||||
{selectedFiles.map((file, idx) => (
|
||||
<li key={file.name + idx} className={'truncate'} title={file.name}>
|
||||
{file.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Clear selection button */}
|
||||
<ButtonComponent
|
||||
label={'Clear selection'}
|
||||
buttonHierarchy={'secondary'}
|
||||
onClick={handleClear}
|
||||
disabled={disabled || selectedFiles.length === 0}
|
||||
colspan={1}
|
||||
/>
|
||||
|
||||
{/* Select files button */}
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
label={label}
|
||||
buttonHierarchy={'primary'}
|
||||
onClick={handleSelectFiles}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { FileUploadComponent }
|
||||
67
src/MaksIT.WebUI/src/components/editors/ListBoxComponent.tsx
Normal file
67
src/MaksIT.WebUI/src/components/editors/ListBoxComponent.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface ListboxComponentProps {
|
||||
label?: string;
|
||||
itemsLabel?: string;
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
items: string[];
|
||||
onChange: (items: string[]) => void;
|
||||
errorText?: string;
|
||||
}
|
||||
|
||||
const ListboxComponent: React.FC<ListboxComponentProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
itemsLabel = 'Items',
|
||||
colspan = 6,
|
||||
items,
|
||||
onChange,
|
||||
errorText
|
||||
} = props
|
||||
|
||||
const [selectedItems, setSelectedItems] = useState<string[]>([])
|
||||
|
||||
const toggleItemSelection = (item: string) => {
|
||||
if (selectedItems.includes(item)) {
|
||||
const updatedSelection = selectedItems.filter(i => i !== item)
|
||||
setSelectedItems(updatedSelection)
|
||||
onChange(updatedSelection)
|
||||
} else {
|
||||
const updatedSelection = [...selectedItems, item]
|
||||
setSelectedItems(updatedSelection)
|
||||
onChange(updatedSelection)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`col-span-${colspan}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>
|
||||
{label}
|
||||
</label>
|
||||
<div className={'flex flex-col'}>
|
||||
<h3>{itemsLabel}</h3>
|
||||
<ul className={'border p-2 w-40 h-64 overflow-auto'}>
|
||||
{items.map(item => (
|
||||
<li
|
||||
key={item}
|
||||
className={`cursor-pointer hover:bg-gray-200 ${selectedItems.includes(item) ? 'bg-gray-300' : ''}`}
|
||||
onClick={() => toggleItemSelection(item)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{errorText && (
|
||||
<p className={'text-red-500 text-xs italic mt-2'}>
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ListboxComponent
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface RadioOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface RadioGroupComponentProps {
|
||||
options: RadioOption[]
|
||||
label?: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
errorText?: string
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const RadioGroupComponent: React.FC<RadioGroupComponentProps> = (props) => {
|
||||
const {
|
||||
options,
|
||||
label,
|
||||
colspan = 6,
|
||||
value = '',
|
||||
onChange,
|
||||
errorText,
|
||||
readOnly = false,
|
||||
disabled = false
|
||||
} = props
|
||||
|
||||
const prevValue = useRef<string>(value)
|
||||
const [selectedValue, setSelectedValue] = useState<string>(value)
|
||||
|
||||
useEffect(() => {
|
||||
prevValue.current = value
|
||||
setSelectedValue(value)
|
||||
}, [value])
|
||||
|
||||
const handleOptionChange = (val: string) => {
|
||||
if (readOnly || disabled) return
|
||||
if (prevValue.current === val) return
|
||||
prevValue.current = val
|
||||
setSelectedValue(val)
|
||||
onChange?.(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
|
||||
<div className={'flex flex-col'}>
|
||||
{options.map(option => {
|
||||
// Use default cursor (arrow) if disabled or readOnly, else pointer
|
||||
const isInactive = disabled || readOnly
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={`flex items-center mb-2 ${disabled ? 'opacity-50' : ''} ${isInactive ? 'cursor-default' : 'cursor-pointer'}`}
|
||||
>
|
||||
<input
|
||||
type={'radio'}
|
||||
value={option.value}
|
||||
checked={selectedValue === option.value}
|
||||
onChange={() => handleOptionChange(option.value)}
|
||||
className={'mr-2'}
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{errorText && (
|
||||
<p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroupComponent }
|
||||
@ -0,0 +1,116 @@
|
||||
import { useState, useCallback, ChangeEvent, useEffect, useRef } from 'react'
|
||||
import { SelectBoxComponent } from '.'
|
||||
import { PagedRequest } from '../../models/PagedRequest'
|
||||
import { ApiRoutes, GetApiRoute } from '../../AppMap'
|
||||
import { useAppDispatch } from '../../redux/hooks'
|
||||
import { postData } from '../../axiosConfig'
|
||||
import { disableLoader, enableLoader } from '../../redux/slices/loaderSlice'
|
||||
import { PagedResponse } from '../../models/PagedResponse'
|
||||
import { SearchResponseBase } from '../../models/SearchResponseBase'
|
||||
import { deepEqual } from '../../functions'
|
||||
|
||||
interface RemoteSelectBoxProps<TRequest extends PagedRequest> {
|
||||
apiRoute: ApiRoutes
|
||||
additionalFilters?: TRequest
|
||||
|
||||
label: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
errorText?: string
|
||||
|
||||
// Field used to compare with the value
|
||||
idField?: string
|
||||
// Fields to search against when filtering options
|
||||
filterFields?: string[]
|
||||
|
||||
value?: string | number
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSelectBoxProps<TRequest>) => {
|
||||
|
||||
const {
|
||||
apiRoute,
|
||||
additionalFilters,
|
||||
|
||||
label,
|
||||
colspan = 12,
|
||||
errorText,
|
||||
|
||||
idField = 'id',
|
||||
filterFields = ['name'],
|
||||
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
} = props
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [options, setOptions] = useState<SearchResponseBase []>([])
|
||||
|
||||
const prevPagedRequest = useRef<TRequest | null>(null)
|
||||
|
||||
const handleFilterChange = useCallback((filters?: string, showLoader: boolean = false) => {
|
||||
const pagedRequest = {
|
||||
pageSize: 10,
|
||||
filters,
|
||||
...additionalFilters
|
||||
} as TRequest
|
||||
|
||||
if (deepEqual(pagedRequest, prevPagedRequest.current))
|
||||
return
|
||||
|
||||
prevPagedRequest.current = pagedRequest
|
||||
|
||||
if (!showLoader)
|
||||
dispatch(disableLoader())
|
||||
|
||||
postData<TRequest, PagedResponse<SearchResponseBase>>(GetApiRoute(apiRoute).route, pagedRequest)
|
||||
.then((response) => {
|
||||
if (!response) return
|
||||
setOptions(response.items)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('RemoteSelectBox fetch error:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
dispatch(enableLoader())
|
||||
})
|
||||
}, [apiRoute, additionalFilters, dispatch])
|
||||
|
||||
// Initialize options on mount
|
||||
useEffect(() => {
|
||||
handleFilterChange(undefined, true)
|
||||
}, [handleFilterChange])
|
||||
|
||||
|
||||
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
return <SelectBoxComponent
|
||||
colspan={colspan}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
options={options?.map(item => {
|
||||
return {
|
||||
value: item.id,
|
||||
label: item.name
|
||||
}
|
||||
})}
|
||||
idField={idField}
|
||||
filterFields={filterFields}
|
||||
onFilterChange={(text) => handleFilterChange(text, false)}
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
errorText={errorText}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
}
|
||||
|
||||
export {
|
||||
RemoteSelectBoxComponent
|
||||
}
|
||||
147
src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx
Normal file
147
src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { Copy, Dices, Eye, EyeOff } from 'lucide-react'
|
||||
import { ChangeEvent, FC, useRef, useState } from 'react'
|
||||
import { TrngResponse } from '../../models/TrngResponse'
|
||||
import { getData } from '../../axiosConfig'
|
||||
import { ApiRoutes, GetApiRoute } from '../../AppMap'
|
||||
|
||||
|
||||
interface PasswordGeneratorProps {
|
||||
label: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
errorText?: string
|
||||
value?: string
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
enableCopy?: boolean
|
||||
enableGenerate?: boolean
|
||||
}
|
||||
|
||||
const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
colspan = 12,
|
||||
errorText,
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
enableCopy = false,
|
||||
enableGenerate = false
|
||||
} = props
|
||||
|
||||
const prevValue = useRef<string>(value)
|
||||
|
||||
// Stato locale per alternare la visibilità della password
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleOnChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (prevValue.current === e.target.value)
|
||||
return
|
||||
|
||||
prevValue.current = e.target.value
|
||||
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
const handleGenerateSecret = () => {
|
||||
getData<TrngResponse>(`${GetApiRoute(ApiRoutes.generateSecret).route}`)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
|
||||
const fakeEvent = {
|
||||
target: { value: response.secret }
|
||||
} as ChangeEvent<HTMLInputElement>
|
||||
|
||||
handleOnChange(fakeEvent)
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!value) return
|
||||
|
||||
await navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
// Controlla se c'è contenuto per mostrare il pulsante di toggle
|
||||
const hasContent = String(value).length > 0
|
||||
|
||||
const actionButtons = () => {
|
||||
|
||||
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||
|
||||
return [
|
||||
hasContent && (
|
||||
<button
|
||||
key={'eye'}
|
||||
type={'button'}
|
||||
onClick={() => setShowPassword(prev => !prev)}
|
||||
className={className}
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff /> : <Eye />}
|
||||
</button>
|
||||
),
|
||||
|
||||
enableGenerate && !readOnly && (
|
||||
<button
|
||||
key={'generate'}
|
||||
type={'button'}
|
||||
onClick={handleGenerateSecret}
|
||||
className={className}
|
||||
tabIndex={-1}
|
||||
aria-label={'Generate secret'}
|
||||
>
|
||||
<Dices />
|
||||
</button>
|
||||
),
|
||||
enableCopy && hasContent && (
|
||||
<button
|
||||
key={'copy'}
|
||||
type={'button'}
|
||||
onClick={handleCopy}
|
||||
className={className}
|
||||
tabIndex={-1}
|
||||
aria-label={'Copy secret'}
|
||||
>
|
||||
<Copy />
|
||||
</button>
|
||||
),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||
|
||||
<div className={'relative'}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
placeholder={placeholder}
|
||||
className={`
|
||||
shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
|
||||
${errorText ? 'border-red-500' : ''}
|
||||
${readOnly ? 'bg-gray-100 text-gray-500' : ''}
|
||||
`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Action buttons container */}
|
||||
<div
|
||||
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
|
||||
>
|
||||
{actionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SecretComponent }
|
||||
220
src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx
Normal file
220
src/MaksIT.WebUI/src/components/editors/SelectBoxComponent.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
import { debounce } from 'lodash'
|
||||
import { CircleX } from 'lucide-react'
|
||||
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
export interface SelectBoxComponentOption {
|
||||
value: string | number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SelectBoxComponentProps {
|
||||
label: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
errorText?: string
|
||||
options?: SelectBoxComponentOption[]
|
||||
|
||||
// Field used to compare with the value
|
||||
idField?: string
|
||||
// Fields to search against when filtering options
|
||||
filterFields?: string[]
|
||||
// Callback function called with a filter string, debounced
|
||||
onFilterChange?: (filters: string) => void
|
||||
|
||||
value?: string | number
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const SelectBoxComponent: FC<SelectBoxComponentProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
colspan = 12,
|
||||
errorText,
|
||||
options = [],
|
||||
|
||||
idField = 'id',
|
||||
filterFields,
|
||||
onFilterChange,
|
||||
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
} = props
|
||||
|
||||
// Local state to control dropdown visibility and current filter text
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [filterValue, setFilterValue] = useState<string>('')
|
||||
|
||||
// Memoized debounced callback for filter changes.
|
||||
const debounceOnFilterChange = useMemo(() => {
|
||||
return onFilterChange ? debounce(onFilterChange, 500) : undefined
|
||||
}, [onFilterChange])
|
||||
|
||||
// Refs to store previous values to detect changes
|
||||
const initRef = useRef(false)
|
||||
const prevValue = useRef(value)
|
||||
const prevFilterValue = useRef(filterValue)
|
||||
|
||||
// Update the selected value and notify parent via onValueChange callback.
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: string | number) => {
|
||||
prevValue.current = newValue
|
||||
// Simulate a ChangeEvent with the new value
|
||||
onChange?.({ target: { value: newValue } } as ChangeEvent<HTMLInputElement>)
|
||||
},
|
||||
[onChange]
|
||||
)
|
||||
|
||||
// Handle input changes for filtering options.
|
||||
const handleFilterChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (disabled) return
|
||||
const newFilter = e.target.value
|
||||
setFilterValue(newFilter)
|
||||
|
||||
// If filter value hasn't changed, exit early.
|
||||
if (prevFilterValue.current === newFilter) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build a filter query string based on the filterFields.
|
||||
const query = filterFields
|
||||
?.map((field) => `${field}.Contains("${newFilter}")`)
|
||||
.filter(Boolean)
|
||||
.join(' || ') ?? ''
|
||||
|
||||
// If debounced filter callback is provided, invoke it.
|
||||
if (debounceOnFilterChange) {
|
||||
debounceOnFilterChange(query)
|
||||
}
|
||||
|
||||
// Clear the selected value when user types in filter.
|
||||
if (showDropdown) {
|
||||
handleValueChange('')
|
||||
}
|
||||
|
||||
prevFilterValue.current = newFilter
|
||||
},
|
||||
[filterFields, debounceOnFilterChange, showDropdown, handleValueChange, disabled]
|
||||
)
|
||||
|
||||
// Effect to sync external value with filter text and trigger filtering.
|
||||
useEffect(() => {
|
||||
// When value is cleared, also clear the filter.
|
||||
if (value === '') {
|
||||
if (prevValue.current !== value) {
|
||||
// Simulate clearing the filter input.
|
||||
handleFilterChange({ target: { value: '' } } as ChangeEvent<HTMLInputElement>)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Find the option that matches the current value.
|
||||
const selectedOption = options.find((option) => option.value === value)
|
||||
if (selectedOption) {
|
||||
if (filterValue !== selectedOption.label) {
|
||||
setFilterValue(selectedOption.label) // Only update if the filterValue is different.
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// If the value does not correspond to an existing option,
|
||||
// trigger filtering using the idField.
|
||||
if (debounceOnFilterChange && !initRef.current) {
|
||||
debounceOnFilterChange(`${idField} == "${value}"`)
|
||||
initRef.current = true
|
||||
}
|
||||
}, [value, filterValue, options, idField, debounceOnFilterChange, handleFilterChange])
|
||||
|
||||
// Handle click on an option from the dropdown.
|
||||
const handleOptionClick = (optionValue: string | number) => {
|
||||
if (disabled) return
|
||||
// Update the selected value.
|
||||
handleValueChange(optionValue)
|
||||
// Update the input to display the selected option's label.
|
||||
const selectedOption = options.find((option) => option.value === optionValue)
|
||||
setFilterValue(selectedOption?.label ?? '')
|
||||
// Close the dropdown.
|
||||
setShowDropdown(false)
|
||||
}
|
||||
|
||||
const actionButtons = () => {
|
||||
const className = 'p-1 text-gray-600 hover:text-gray-800 bg-white'
|
||||
if (disabled) return null
|
||||
return [
|
||||
!!filterValue && !readOnly && (
|
||||
<button
|
||||
key={'clear'}
|
||||
type={'button'}
|
||||
onClick={() => {
|
||||
setFilterValue('')
|
||||
handleValueChange('')
|
||||
if (debounceOnFilterChange) debounceOnFilterChange('')
|
||||
}}
|
||||
className={className}
|
||||
tabIndex={-1}
|
||||
aria-label={'Clear'}
|
||||
>
|
||||
<CircleX />
|
||||
</button>
|
||||
),
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
{/* Label for the select input */}
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||
<div className={'relative'}>
|
||||
<input
|
||||
type={'text'}
|
||||
value={filterValue}
|
||||
onChange={handleFilterChange}
|
||||
placeholder={placeholder}
|
||||
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline
|
||||
${errorText ? 'border-red-500' : ''}
|
||||
${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}
|
||||
${readOnly && !disabled ? 'text-gray-500 cursor-default' : ''}`}
|
||||
disabled={readOnly || disabled}
|
||||
// Open dropdown when input is focused.
|
||||
onFocus={() => { if (!disabled) setShowDropdown(true) }}
|
||||
// Delay closing dropdown to allow click events on options.
|
||||
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
className={'absolute top-0 bottom-0 right-2 flex items-center gap-1 pointer-events-auto'}
|
||||
>
|
||||
{actionButtons()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDropdown && !disabled && (
|
||||
<div className={'absolute left-0 right-0 bg-white border border-gray-300 rounded mt-1 w-full shadow-lg z-10'}>
|
||||
{options.length > 0 ? (
|
||||
options.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={'px-4 py-2 cursor-pointer hover:bg-gray-200'}
|
||||
onMouseDown={() => handleOptionClick(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className={'px-4 py-2 text-gray-500'}>No options found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SelectBoxComponent }
|
||||
120
src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx
Normal file
120
src/MaksIT.WebUI/src/components/editors/TextBoxComponent.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { ChangeEvent, FC, useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface TextBoxComponentProps {
|
||||
label: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
errorText?: string
|
||||
value?: string | number
|
||||
onChange?: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
|
||||
type?: 'text' | 'password' | 'textarea' | 'number' | 'email' | 'time'
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const TextBoxComponent: FC<TextBoxComponentProps> = (props) => {
|
||||
|
||||
const {
|
||||
label,
|
||||
colspan = 12,
|
||||
errorText,
|
||||
value = '',
|
||||
onChange,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
} = props
|
||||
|
||||
const prevValue = useRef<string | number>(value)
|
||||
|
||||
useEffect(() => {
|
||||
prevValue.current = value
|
||||
}, [value])
|
||||
|
||||
// Stato locale per gestire la visibilità della password
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
const handleOnChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (prevValue.current === e.target.value)
|
||||
return
|
||||
|
||||
prevValue.current = e.target.value
|
||||
|
||||
onChange?.(e)
|
||||
}
|
||||
|
||||
// Se il type è "textarea", comportamento invariato
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
placeholder={placeholder}
|
||||
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||
errorText ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Verifica se il valore non è vuoto (per tipo "password" useremo questa condizione)
|
||||
const hasContent = String(value).length > 0
|
||||
|
||||
return (
|
||||
<div className={`mb-4 ${colspan ? `col-span-${colspan}` : 'w-full'}`}>
|
||||
<label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>
|
||||
|
||||
{type === 'password' ? (
|
||||
// Wrapper che contiene input e bottone show/hide, ma bottone solo se c'è contenuto
|
||||
<div className={'relative'}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
placeholder={placeholder}
|
||||
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||
errorText ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{hasContent && (
|
||||
<button
|
||||
type={'button'}
|
||||
onClick={() => setShowPassword(prev => !prev)}
|
||||
className={'absolute inset-y-0 right-0 pr-3 flex items-center text-gray-600'}
|
||||
tabIndex={-1} // Non interferisce con l'ordine di tabulazione
|
||||
>
|
||||
{showPassword ? <Eye /> : <EyeOff />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Input normale per tutti gli altri tipi (text, number, email, time)
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleOnChange}
|
||||
placeholder={placeholder}
|
||||
className={`shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline ${
|
||||
errorText ? 'border-red-500' : ''
|
||||
} ${disabled ? 'bg-gray-100 text-gray-500 cursor-default' : 'bg-white'}${readOnly && !disabled ? ' text-gray-500 cursor-default' : ''}`}
|
||||
readOnly={readOnly}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{errorText && <p className={'text-red-500 text-xs italic mt-2'}>{errorText}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TextBoxComponent }
|
||||
@ -0,0 +1,85 @@
|
||||
import React, { useState, ReactNode } from 'react'
|
||||
|
||||
interface TreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
content?: ReactNode | ((ids: string[]) => ReactNode); // Custom content at each node
|
||||
children?: TreeNode[]; // Nested nodes for infinite depth
|
||||
defaultCollapsed?: boolean; // Default collapse/expand state
|
||||
}
|
||||
|
||||
interface TreeViewProps {
|
||||
data: TreeNode[];
|
||||
label?: string;
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
}
|
||||
|
||||
const TreeViewComponent: React.FC<TreeViewProps> = (props) => {
|
||||
|
||||
const { data, label, colspan = 6 } = props
|
||||
|
||||
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>(
|
||||
() =>
|
||||
data.reduce((acc, node) => {
|
||||
const initCollapse = (node: TreeNode, collapsed: Record<string, boolean>): Record<string, boolean> => {
|
||||
collapsed[node.id] = node.defaultCollapsed ?? true
|
||||
if (node.children) {
|
||||
node.children.forEach((child) => initCollapse(child, collapsed))
|
||||
}
|
||||
return collapsed
|
||||
}
|
||||
return initCollapse(node, acc)
|
||||
}, {})
|
||||
)
|
||||
|
||||
const toggleCollapse = (id: string) => {
|
||||
setCollapsedItems((prev) => ({
|
||||
...prev,
|
||||
[id]: !prev[id],
|
||||
}))
|
||||
}
|
||||
|
||||
const renderTree = (nodes: TreeNode[], parentIds: string[] = []) => {
|
||||
return nodes.map((node) => {
|
||||
const nodeIds = [...parentIds, node.id]
|
||||
|
||||
return (
|
||||
<div key={node.id} className={'mb-2 ml-4'}>
|
||||
{/* Node Header */}
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<div className={'flex items-center'}>
|
||||
<span className={'font-bold'}>{node.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleCollapse(node.id)}
|
||||
className={'text-sm text-blue-500 focus:outline-none'}
|
||||
>
|
||||
{collapsedItems[node.id] ? 'Expand' : 'Collapse'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
{!collapsedItems[node.id] && (
|
||||
<>
|
||||
{node.content && (
|
||||
<div className={'ml-4 mt-2'}>
|
||||
{typeof node.content === 'function' ? node.content(nodeIds) : node.content}
|
||||
</div>
|
||||
)}
|
||||
{node.children && <div className={'ml-4'}>{renderTree(node.children, nodeIds)}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`col-span-${colspan}`}>
|
||||
{label && <label className={'block text-gray-700 text-sm font-bold mb-2'}>{label}</label>}
|
||||
<div className={'border p-4 rounded-md'}>{renderTree(data)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TreeViewComponent }
|
||||
28
src/MaksIT.WebUI/src/components/editors/index.ts
Normal file
28
src/MaksIT.WebUI/src/components/editors/index.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { ButtonComponent } from './ButtonComponent'
|
||||
import { CheckBoxComponent } from './CheckBoxComponent'
|
||||
import { TextBoxComponent } from './TextBoxComponent'
|
||||
import { DateTimePickerComponent } from './DateTimePickerComponent'
|
||||
import { DualListboxComponent } from './DualListboxComponent'
|
||||
import { TreeViewComponent } from './TreeViewComponent'
|
||||
import { ListboxComponent } from './ListBoxComponent'
|
||||
import { SecretComponent } from './SecretComponent'
|
||||
import { SelectBoxComponent } from './SelectBoxComponent'
|
||||
import { RemoteSelectBoxComponent } from './RemoteSelectBoxComponent'
|
||||
import { RadioGroupComponent } from './RadioGroupComponent'
|
||||
import { FileUploadComponent } from './FileUploadComponent'
|
||||
|
||||
|
||||
export {
|
||||
ButtonComponent,
|
||||
CheckBoxComponent,
|
||||
DateTimePickerComponent,
|
||||
TextBoxComponent,
|
||||
DualListboxComponent,
|
||||
TreeViewComponent,
|
||||
ListboxComponent,
|
||||
RemoteSelectBoxComponent,
|
||||
SecretComponent,
|
||||
SelectBoxComponent,
|
||||
RadioGroupComponent,
|
||||
FileUploadComponent,
|
||||
}
|
||||
41
src/MaksIT.WebUI/src/entities/CacheAccount.ts
Normal file
41
src/MaksIT.WebUI/src/entities/CacheAccount.ts
Normal file
@ -0,0 +1,41 @@
|
||||
|
||||
import { PatchAccountRequest } from '../models/letsEncryptServer/account/requests/PatchAccountRequest'
|
||||
import { PatchOperation } from '../models/PatchOperation'
|
||||
import { CacheAccountHostname } from './CacheAccountHostname'
|
||||
|
||||
export interface CacheAccount {
|
||||
accountId: string
|
||||
isDisabled: boolean
|
||||
description: string
|
||||
contacts: string[]
|
||||
challengeType?: string
|
||||
hostnames?: CacheAccountHostname[]
|
||||
isEditMode: boolean
|
||||
isStaging: boolean
|
||||
}
|
||||
|
||||
const toPatchAccountRequest = (account: CacheAccount): PatchAccountRequest => {
|
||||
return {
|
||||
description: { op: PatchOperation.None, value: account.description },
|
||||
isDisabled: { op: PatchOperation.None, value: account.isDisabled },
|
||||
contacts: account.contacts.map((contact, index) => ({
|
||||
index: index,
|
||||
op: PatchOperation.None,
|
||||
value: contact
|
||||
})),
|
||||
hostnames: account.hostnames?.map((hostname, index) => ({
|
||||
hostname: {
|
||||
index: index,
|
||||
op: PatchOperation.None,
|
||||
value: hostname.hostname
|
||||
},
|
||||
isDisabled: {
|
||||
index: index,
|
||||
op: PatchOperation.None,
|
||||
value: hostname.isDisabled
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
export { toPatchAccountRequest }
|
||||
6
src/MaksIT.WebUI/src/entities/CacheAccountHostname.ts
Normal file
6
src/MaksIT.WebUI/src/entities/CacheAccountHostname.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface CacheAccountHostname {
|
||||
hostname: string
|
||||
expires: Date
|
||||
isUpcomingExpire: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
4
src/MaksIT.WebUI/src/entities/ChallengeType.ts
Normal file
4
src/MaksIT.WebUI/src/entities/ChallengeType.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export enum ChallengeType {
|
||||
http01 = 'http-01',
|
||||
dns01 = 'dns-01'
|
||||
}
|
||||
154
src/MaksIT.WebUI/src/forms/Home.tsx
Normal file
154
src/MaksIT.WebUI/src/forms/Home.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||
import { ButtonComponent, CheckBoxComponent, RadioGroupComponent, SelectBoxComponent } from '../components/editors'
|
||||
import { CacheAccount } from '../entities/CacheAccount'
|
||||
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
|
||||
import { deleteData, getData } from '../axiosConfig'
|
||||
import { ApiRoutes, GetApiRoute } from '../AppMap'
|
||||
import { enumToArr, formatISODateString } from '../functions'
|
||||
import { ChallengeType } from '../entities/ChallengeType'
|
||||
import { Radio } from 'lucide-react'
|
||||
|
||||
|
||||
const Home: FC = () => {
|
||||
const [rawd, setRawd] = useState<GetAccountResponse[]>([])
|
||||
const [editingAccount, setEditingAccount] = useState<GetAccountResponse | null>(
|
||||
null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
console.log(GetApiRoute(ApiRoutes.ACCOUNTS).route)
|
||||
|
||||
getData<GetAccountResponse []>(GetApiRoute(ApiRoutes.ACCOUNTS).route).then((response) => {
|
||||
if (!response) return
|
||||
|
||||
setRawd(response)
|
||||
})
|
||||
|
||||
}, [])
|
||||
|
||||
const handleAccountUpdate = (updatedAccount: CacheAccount) => {
|
||||
// setAccounts(
|
||||
// accounts.map((account) =>
|
||||
// account.accountId === updatedAccount.accountId
|
||||
// ? updatedAccount
|
||||
// : account
|
||||
// )
|
||||
// )
|
||||
}
|
||||
|
||||
const deleteAccount = (accountId: string) => {
|
||||
deleteData<void>(
|
||||
GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
|
||||
.route.replace('{accountId}', accountId)
|
||||
).then((result) => {
|
||||
if (!result) return
|
||||
|
||||
setRawd(rawd.filter((account) => account.accountId !== accountId))
|
||||
})
|
||||
}
|
||||
|
||||
return <FormContainer>
|
||||
<FormHeader>Home</FormHeader>
|
||||
<FormContent>
|
||||
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||
{rawd.length === 0 ?
|
||||
<div className={'text-center text-gray-600 col-span-12'}>
|
||||
No accounts registered.
|
||||
</div> :
|
||||
rawd.map((acc) => (
|
||||
<div key={acc.accountId} className={'bg-white shadow-lg rounded-lg p-6 mb-6 col-span-12'}>
|
||||
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||
<h2 className={'col-span-8'}>
|
||||
Account: {acc.accountId}
|
||||
</h2>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
onClick={() => deleteAccount(acc.accountId)}
|
||||
label={'Delete'}
|
||||
buttonHierarchy={'error'}
|
||||
/>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
onClick={() => setEditingAccount(acc)}
|
||||
label={'Edit'}
|
||||
/>
|
||||
<h3 className={'col-span-12'}>
|
||||
Description: {acc.description}
|
||||
</h3>
|
||||
<CheckBoxComponent
|
||||
colspan={12}
|
||||
value={acc.isDisabled}
|
||||
label={'Disabled'}
|
||||
disabled={true}
|
||||
/>
|
||||
<h3 className={'col-span-12'}>Contacts:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
{acc.contacts.map((contact) => (
|
||||
<li key={contact} className={'pb-2'}>
|
||||
{contact}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<RadioGroupComponent
|
||||
colspan={12}
|
||||
label={'LetsEncrypt Environment'}
|
||||
options={[
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'production', label: 'Production' }
|
||||
]}
|
||||
|
||||
value={acc.challengeType ? 'staging' : 'production'}
|
||||
disabled={true}
|
||||
/>
|
||||
<h3 className={'col-span-12'}>Hostnames:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
{acc.hostnames?.map((hostname) => (
|
||||
<li key={hostname.hostname} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||
<span className={'col-span-3'}>{hostname.hostname}</span>
|
||||
<span className={'col-span-3'}>{formatISODateString(hostname.expires)}</span>
|
||||
<span className={'col-span-3'}>
|
||||
<span className={`${hostname.isUpcomingExpire
|
||||
? 'bg-yellow-200 text-yellow-800'
|
||||
: 'bg-green-200 text-green-800'}`}>{hostname.isUpcomingExpire
|
||||
? 'Upcoming'
|
||||
: 'Not Upcoming'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<CheckBoxComponent
|
||||
colspan={3}
|
||||
value={hostname.isDisabled}
|
||||
label={'Disabled'}
|
||||
disabled={true}
|
||||
/>
|
||||
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<SelectBoxComponent
|
||||
label={'Environment'}
|
||||
options={[
|
||||
{ value: 'production', label: 'Production' },
|
||||
{ value: 'staging', label: 'Staging' }
|
||||
]}
|
||||
value={acc.isStaging ? 'staging' : 'production'}
|
||||
disabled={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</FormContent>
|
||||
<FormFooter />
|
||||
</FormContainer>
|
||||
}
|
||||
|
||||
export { Home }
|
||||
227
src/MaksIT.WebUI/src/forms/Register.tsx
Normal file
227
src/MaksIT.WebUI/src/forms/Register.tsx
Normal file
@ -0,0 +1,227 @@
|
||||
import { FC } from 'react'
|
||||
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||
import { postData } from '../axiosConfig'
|
||||
import { GetAccountResponse } from '../models/letsEncryptServer/account/responses/GetAccountResponse'
|
||||
import { ApiRoutes, GetApiRoute } from '../AppMap'
|
||||
import { ButtonComponent, RadioGroupComponent, SelectBoxComponent, TextBoxComponent } from '../components/editors'
|
||||
import { ChallengeType } from '../entities/ChallengeType'
|
||||
import z, { array, boolean, object, Schema, string } from 'zod'
|
||||
import { useFormState } from '../hooks/useFormState'
|
||||
import { enumToArr } from '../functions'
|
||||
import { PostAccountRequest, PostAccountRequestSchema } from '../models/letsEncryptServer/account/requests/PostAccountRequest'
|
||||
import { addToast } from '../components/Toast/addToast'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
|
||||
interface RegisterFormProps {
|
||||
description: string
|
||||
|
||||
contact: string
|
||||
contacts: string[]
|
||||
|
||||
hostname: string
|
||||
hostnames: string[]
|
||||
|
||||
challengeType: ChallengeType
|
||||
isStaging: boolean
|
||||
}
|
||||
|
||||
const RegisterFormProto = (): RegisterFormProps => ({
|
||||
description: '',
|
||||
|
||||
contact: '',
|
||||
contacts: [],
|
||||
|
||||
hostname: '',
|
||||
hostnames: [],
|
||||
|
||||
challengeType: ChallengeType.http01,
|
||||
isStaging: true
|
||||
})
|
||||
|
||||
const RegisterFormSchema: Schema<RegisterFormProps> = object({
|
||||
description: string(),
|
||||
|
||||
contact: string(),
|
||||
contacts: array(string()),
|
||||
|
||||
hostname: string(),
|
||||
hostnames: array(string()),
|
||||
|
||||
challengeType: z.enum(ChallengeType),
|
||||
isStaging: boolean()
|
||||
})
|
||||
|
||||
interface RegisterProps {
|
||||
}
|
||||
|
||||
const Register: FC<RegisterProps> = () => {
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const {
|
||||
formState,
|
||||
errors,
|
||||
formIsValid,
|
||||
handleInputChange
|
||||
} = useFormState<RegisterFormProps>({
|
||||
initialState: RegisterFormProto(),
|
||||
validationSchema: RegisterFormSchema,
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formIsValid) return
|
||||
|
||||
const requestData: PostAccountRequest = {
|
||||
description: formState.description,
|
||||
contacts: formState.contacts,
|
||||
hostnames: formState.hostnames,
|
||||
challengeType: formState.challengeType,
|
||||
isStaging: formState.isStaging,
|
||||
}
|
||||
|
||||
const request = PostAccountRequestSchema.safeParse(requestData)
|
||||
|
||||
if (!request.success) {
|
||||
request.error.issues.forEach(error => {
|
||||
addToast(error.message, 'error')
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
postData<PostAccountRequest, GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_POST).route, request.data)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
|
||||
navigate('/')
|
||||
})
|
||||
}
|
||||
|
||||
return <FormContainer>
|
||||
<FormHeader>Register</FormHeader>
|
||||
<FormContent>
|
||||
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||
<TextBoxComponent
|
||||
colspan={12}
|
||||
label={'Account Description'}
|
||||
value={formState.description}
|
||||
onChange={(e) => handleInputChange('description', e.target.value)}
|
||||
placeholder={'Account Description'}
|
||||
errorText={errors.description}
|
||||
/>
|
||||
<h3 className={'col-span-12'}>Contacts:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
{formState.contacts.map((contact) => (
|
||||
<li key={contact} className={'grid grid-cols-12 gap-4 w-full pb-2'}>
|
||||
<span className={'col-span-10'}>{contact}</span>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
label={'TRASH'}
|
||||
onClick={() => {
|
||||
const updatedContacts = formState.contacts.filter(c => c !== contact)
|
||||
handleInputChange('contacts', updatedContacts)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<TextBoxComponent
|
||||
colspan={10}
|
||||
label={'New Contact'}
|
||||
value={formState.contact}
|
||||
onChange={(e) => {
|
||||
if (formState.contacts.includes(e.target.value))
|
||||
return
|
||||
|
||||
handleInputChange('contact', e.target.value)
|
||||
}}
|
||||
placeholder={'Add contact'}
|
||||
type={'text'}
|
||||
errorText={errors.contact}
|
||||
/>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
label={'PLUS'}
|
||||
onClick={() => {
|
||||
handleInputChange('contacts', [...formState.contacts, formState.contact])
|
||||
handleInputChange('contact', '')
|
||||
}}
|
||||
disabled={formState.contact.trim() === ''}
|
||||
/>
|
||||
<div className={'col-span-12'}>
|
||||
<SelectBoxComponent
|
||||
label={'Challenge Type'}
|
||||
options={enumToArr(ChallengeType).map(ct => ({ value: ct.value, label: ct.displayValue }))}
|
||||
value={formState.challengeType}
|
||||
placeholder={'Select Challenge Type'}
|
||||
onChange={(e) => handleInputChange('challengeType', e.target.value)}
|
||||
errorText={errors.challengeType}
|
||||
/>
|
||||
</div>
|
||||
<h3 className={'col-span-12'}>Hostnames:</h3>
|
||||
<ul className={'col-span-12'}>
|
||||
{formState.hostnames.map((hostname) => (
|
||||
<li key={hostname} className={'grid grid-cols-12 gap-4 w-full'}>
|
||||
<span className={'col-span-10'}>{hostname}</span>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
label={'TRASH'}
|
||||
onClick={() => {
|
||||
const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
|
||||
handleInputChange('hostnames', updatedHostnames)
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<TextBoxComponent
|
||||
colspan={10}
|
||||
label={'New Hostname'}
|
||||
value={formState.hostname}
|
||||
onChange={(e) => {
|
||||
if (formState.hostnames.includes(e.target.value))
|
||||
return
|
||||
|
||||
handleInputChange('hostname', e.target.value)
|
||||
}}
|
||||
placeholder={'Add hostname'}
|
||||
type={'text'}
|
||||
errorText={errors.hostname}
|
||||
/>
|
||||
<ButtonComponent
|
||||
colspan={2}
|
||||
label={'PLUS'}
|
||||
onClick={() => {
|
||||
handleInputChange('hostnames', [...formState.hostnames, formState.hostname])
|
||||
handleInputChange('hostname', '')
|
||||
}}
|
||||
disabled={formState.hostname.trim() === ''}
|
||||
/>
|
||||
<RadioGroupComponent
|
||||
colspan={12}
|
||||
label={'LetsEncrypt Environment'}
|
||||
options={[
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'production', label: 'Production' }
|
||||
]}
|
||||
|
||||
value={formState.isStaging ? 'staging' : 'production'}
|
||||
onChange={(value) => {
|
||||
handleInputChange('isStaging', value === 'staging')
|
||||
}}
|
||||
errorText={errors.isStaging}
|
||||
/>
|
||||
</div>
|
||||
</FormContent>
|
||||
<FormFooter rightChildren={
|
||||
<ButtonComponent
|
||||
label={'Create Account'}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
} />
|
||||
</FormContainer>
|
||||
}
|
||||
|
||||
|
||||
export { Register }
|
||||
42
src/MaksIT.WebUI/src/forms/Utilities.tsx
Normal file
42
src/MaksIT.WebUI/src/forms/Utilities.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { FC, useState } from 'react'
|
||||
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
|
||||
import { ButtonComponent, FileUploadComponent } from '../components/editors'
|
||||
|
||||
const Utilities: FC = () => {
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
return <FormContainer>
|
||||
<FormHeader>Utilities</FormHeader>
|
||||
<FormContent>
|
||||
<div className={'grid grid-cols-12 gap-4 w-full'}>
|
||||
<ButtonComponent
|
||||
colspan={3}
|
||||
label={'Test agent'}
|
||||
buttonHierarchy={'primary'}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
|
||||
<ButtonComponent
|
||||
colspan={3}
|
||||
label={'Download cache files'}
|
||||
buttonHierarchy={'secondary'}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
|
||||
<FileUploadComponent
|
||||
colspan={6}
|
||||
label={'Upload cache files'}
|
||||
multiple={true}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
|
||||
<span className={'col-span-12'}></span>
|
||||
</div>
|
||||
|
||||
</FormContent>
|
||||
<FormFooter />
|
||||
</FormContainer>
|
||||
}
|
||||
|
||||
export { Utilities }
|
||||
9
src/MaksIT.WebUI/src/functions/acl/index.ts
Normal file
9
src/MaksIT.WebUI/src/functions/acl/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import {
|
||||
parseAclEntry,
|
||||
parseAclEntries
|
||||
} from './parseAclEntry'
|
||||
|
||||
export {
|
||||
parseAclEntry,
|
||||
parseAclEntries,
|
||||
}
|
||||
49
src/MaksIT.WebUI/src/functions/acl/parseAclEntry.ts
Normal file
49
src/MaksIT.WebUI/src/functions/acl/parseAclEntry.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { ScopeEntityType } from '../../models/identity/ScopeEntityType'
|
||||
import { ScopePermission } from '../../models/identity/ScopePermissions'
|
||||
|
||||
export interface AclEntry {
|
||||
entityType: ScopeEntityType,
|
||||
entityId: string,
|
||||
scope: ScopePermission
|
||||
}
|
||||
|
||||
const parseAclEntry = (aclEntry: string): AclEntry | null => {
|
||||
if (typeof aclEntry !== 'string')
|
||||
return null
|
||||
|
||||
const parts = aclEntry.split(':')
|
||||
if (parts.length !== 3)
|
||||
|
||||
return null
|
||||
|
||||
const entityTypeMap: Record<string, ScopeEntityType> = {
|
||||
O: ScopeEntityType.Organization,
|
||||
A: ScopeEntityType.Application,
|
||||
S: ScopeEntityType.Secret,
|
||||
I: ScopeEntityType.Identity,
|
||||
K: ScopeEntityType.ApiKey
|
||||
}
|
||||
|
||||
const entityType = entityTypeMap[parts[0]] ?? 'Unknown'
|
||||
const entityId = parts[1]
|
||||
const scopePermission = parseInt(parts[2], 16) as ScopePermission
|
||||
|
||||
|
||||
const aclEntryResult: AclEntry = {
|
||||
entityType,
|
||||
entityId,
|
||||
scope: scopePermission
|
||||
}
|
||||
|
||||
console.log('Parsed ACL Entry:', aclEntryResult)
|
||||
|
||||
return aclEntryResult
|
||||
}
|
||||
|
||||
const parseAclEntries = (aclEntries: string[]): AclEntry [] => {
|
||||
return aclEntries
|
||||
.map(parseAclEntry)
|
||||
.filter((entry): entry is AclEntry => entry !== null)
|
||||
}
|
||||
|
||||
export { parseAclEntry, parseAclEntries }
|
||||
19
src/MaksIT.WebUI/src/functions/date/formatISODateString.ts
Normal file
19
src/MaksIT.WebUI/src/functions/date/formatISODateString.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { parseISO, isValid, format } from 'date-fns'
|
||||
|
||||
const DISPLAY_FORMAT = 'yyyy-MM-dd HH:mm'
|
||||
|
||||
const formatISODateString = (isoString: string): string => {
|
||||
if (!isoString)
|
||||
return ''
|
||||
|
||||
const parsed = parseISO(isoString)
|
||||
|
||||
if (!isValid(parsed))
|
||||
return 'ISO Date String is invalid'
|
||||
|
||||
return format(parsed, DISPLAY_FORMAT)
|
||||
}
|
||||
|
||||
export {
|
||||
formatISODateString
|
||||
}
|
||||
7
src/MaksIT.WebUI/src/functions/date/index.ts
Normal file
7
src/MaksIT.WebUI/src/functions/date/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { isValidISODateString } from './isValidDateString'
|
||||
import { formatISODateString } from './formatISODateString'
|
||||
|
||||
export {
|
||||
isValidISODateString,
|
||||
formatISODateString
|
||||
}
|
||||
15
src/MaksIT.WebUI/src/functions/date/isValidDateString.ts
Normal file
15
src/MaksIT.WebUI/src/functions/date/isValidDateString.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { parseISO, isValid } from 'date-fns'
|
||||
|
||||
|
||||
|
||||
const isValidISODateString = (dateString: string): boolean => {
|
||||
if (!dateString) return false
|
||||
const parsed = parseISO(dateString)
|
||||
return isValid(parsed)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export {
|
||||
isValidISODateString
|
||||
}
|
||||
35
src/MaksIT.WebUI/src/functions/deep/deepCopy.ts
Normal file
35
src/MaksIT.WebUI/src/functions/deep/deepCopy.ts
Normal file
@ -0,0 +1,35 @@
|
||||
const deepCopy = <T>(obj: T, seen = new WeakMap<object, unknown>()): T =>{
|
||||
if (
|
||||
obj === null ||
|
||||
typeof obj !== 'object' ||
|
||||
obj instanceof Date ||
|
||||
obj instanceof RegExp ||
|
||||
obj instanceof Function
|
||||
) {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (seen.has(obj as object)) {
|
||||
return seen.get(obj as object) as T
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
const arrCopy: unknown[] = []
|
||||
seen.set(obj, arrCopy)
|
||||
for (const item of obj) {
|
||||
arrCopy.push(deepCopy(item, seen))
|
||||
}
|
||||
return arrCopy as T
|
||||
}
|
||||
|
||||
const objCopy = {} as { [K in keyof T]: T[K] }
|
||||
seen.set(obj, objCopy)
|
||||
|
||||
for (const key of Object.keys(obj) as Array<keyof T>) {
|
||||
objCopy[key] = deepCopy(obj[key], seen)
|
||||
}
|
||||
|
||||
return objCopy
|
||||
}
|
||||
|
||||
export { deepCopy }
|
||||
316
src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
Normal file
316
src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
Normal file
@ -0,0 +1,316 @@
|
||||
import { PatchOperation } from '../../models/PatchOperation'
|
||||
import { deepCopy } from './deepCopy.js'
|
||||
import { deepEqual } from './deepEqual.js'
|
||||
|
||||
type IdLike = string | number | null | undefined
|
||||
|
||||
export type Identifiable<I extends string | number = string | number> = {
|
||||
id?: I | null
|
||||
}
|
||||
|
||||
type OperationBag<K extends string = string> = {
|
||||
operations?: Partial<Record<K | 'collectionItemOperation', PatchOperation>>
|
||||
}
|
||||
|
||||
type EnsureId<T extends Identifiable> = { id?: T['id'] }
|
||||
|
||||
type PlainObject = Record<string, unknown>
|
||||
|
||||
type DeltaArrayItem<T extends Identifiable> = Partial<T> & EnsureId<T> & OperationBag
|
||||
|
||||
/** Policy non-generica: chiavi sempre stringhe */
|
||||
export type ArrayPolicy = {
|
||||
/** Nome del campo “radice” che implica re-parenting (es. 'organizationId') */
|
||||
rootKey?: string
|
||||
/** Nomi degli array figli da trattare in caso di re-parenting (es. ['applicationRoles']) */
|
||||
childArrayKeys?: string[]
|
||||
/** Se true, in re-parenting i figli vengono azzerati (default TRUE) */
|
||||
dropChildrenOnRootChange?: boolean
|
||||
/** Nome del campo ruolo (default 'role') */
|
||||
roleFieldKey?: string
|
||||
/** Se true, quando role diventa null si rimuove l’intero item (default TRUE) */
|
||||
deleteItemWhenRoleRemoved?: boolean
|
||||
}
|
||||
|
||||
export type DeepDeltaOptions<T> = {
|
||||
/** Policy per i campi array del payload (mappati per nome chiave) */
|
||||
arrays?: Partial<Record<Extract<keyof T, string>, ArrayPolicy>>
|
||||
}
|
||||
|
||||
export type Delta<T> =
|
||||
Partial<{
|
||||
[K in keyof T]:
|
||||
T[K] extends (infer U)[]
|
||||
? DeltaArrayItem<(U & Identifiable)>[]
|
||||
: T[K] extends object
|
||||
? Delta<T[K] & OperationBag<Extract<keyof T, string>>>
|
||||
: T[K]
|
||||
}> & OperationBag<Extract<keyof T, string>>
|
||||
|
||||
/** Safe index per evitare TS2536 quando si indicizza su chiavi dinamiche */
|
||||
const getArrayPolicy = <T>(options: DeepDeltaOptions<T> | undefined, key: string): ArrayPolicy | undefined =>{
|
||||
const arrays = options?.arrays as Partial<Record<string, ArrayPolicy>> | undefined
|
||||
return arrays?.[key]
|
||||
}
|
||||
|
||||
const isPlainObject = (value: unknown): value is PlainObject =>
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
|
||||
export const deepDelta = <T extends Record<string, unknown>>(
|
||||
formState: T,
|
||||
backupState: T,
|
||||
options?: DeepDeltaOptions<T>
|
||||
): Delta<T> => {
|
||||
const delta = {} as Delta<T>
|
||||
|
||||
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
|
||||
const ops = (bag.operations ??= {} as Record<string, PatchOperation>)
|
||||
ops[key] = op
|
||||
}
|
||||
|
||||
const calculateDelta = (
|
||||
form: PlainObject,
|
||||
backup: PlainObject,
|
||||
parentDelta: PlainObject & OperationBag
|
||||
) => {
|
||||
const keys = Array.from(new Set([...Object.keys(form), ...Object.keys(backup)]))
|
||||
|
||||
for (const rawKey of keys) {
|
||||
const key = rawKey as keyof T & string
|
||||
const formValue = form[key]
|
||||
const backupValue = backup[key]
|
||||
|
||||
// --- ARRAY ---
|
||||
if (Array.isArray(formValue) && Array.isArray(backupValue)) {
|
||||
const policy = getArrayPolicy(options, key)
|
||||
const arrayDelta = calculateArrayDelta(
|
||||
formValue as Identifiable[],
|
||||
backupValue as Identifiable[],
|
||||
policy
|
||||
)
|
||||
if (arrayDelta.length > 0) {
|
||||
;(parentDelta as Delta<T>)[key] = arrayDelta as unknown as Delta<T>[typeof key]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// --- OBJECT ---
|
||||
if (isPlainObject(formValue) && isPlainObject(backupValue)) {
|
||||
if (!deepEqual(formValue, backupValue)) {
|
||||
const nestedDelta: PlainObject & OperationBag = {}
|
||||
calculateDelta(
|
||||
formValue as PlainObject,
|
||||
(backupValue as PlainObject) ?? {},
|
||||
nestedDelta
|
||||
)
|
||||
if (Object.keys(nestedDelta).length > 0) {
|
||||
;(parentDelta as Delta<T>)[key] = nestedDelta as unknown as Delta<T>[typeof key]
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// --- PRIMITIVE / TYPE CHANGED ---
|
||||
if (!deepEqual(formValue, backupValue)) {
|
||||
;(parentDelta as Delta<T>)[key] = formValue as Delta<T>[typeof key]
|
||||
setOp(parentDelta, key, formValue === null ? PatchOperation.RemoveField : PatchOperation.SetField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calculateArrayDelta = <U extends Identifiable>(
|
||||
formArray: U[],
|
||||
backupArray: U[],
|
||||
policy?: ArrayPolicy
|
||||
): DeltaArrayItem<U>[] => {
|
||||
const arrayDelta: DeltaArrayItem<U>[] = []
|
||||
|
||||
const getId = (item?: U): IdLike => (item ? item.id ?? null : null)
|
||||
const childrenKeys = policy?.childArrayKeys ?? []
|
||||
const dropChildren = policy?.dropChildrenOnRootChange ?? true
|
||||
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
|
||||
const rootKey = policy?.rootKey
|
||||
|
||||
const sameRoot = (f: U, b: U): boolean => {
|
||||
if (!rootKey) return true
|
||||
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
|
||||
}
|
||||
|
||||
// Mappe id → item per lookup veloce
|
||||
const formMap = new Map<string | number, U>()
|
||||
const backupMap = new Map<string | number, U>()
|
||||
for (const item of formArray) {
|
||||
const id = getId(item)
|
||||
if (id !== null && id !== undefined) formMap.set(id as string | number, item)
|
||||
}
|
||||
for (const item of backupArray) {
|
||||
const id = getId(item)
|
||||
if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
|
||||
}
|
||||
|
||||
// 1) Gestione elementi presenti nel form
|
||||
for (const formItem of formArray) {
|
||||
const fid = getId(formItem)
|
||||
|
||||
// 1.a) Nuovo item (senza id)
|
||||
if (fid === null || fid === undefined) {
|
||||
const addItem = {} as DeltaArrayItem<U>
|
||||
Object.assign(addItem, formItem as Partial<U>)
|
||||
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
|
||||
// ⬇️ NON droppiamo i figli su "add": li normalizziamo come AddToCollection
|
||||
for (const ck of childrenKeys) {
|
||||
const v = (addItem as PlainObject)[ck]
|
||||
if (Array.isArray(v)) {
|
||||
const normalized = (v as Identifiable[]).map(child => {
|
||||
const c = {} as DeltaArrayItem<Identifiable>
|
||||
Object.assign(c, child as Partial<Identifiable>)
|
||||
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
return c
|
||||
})
|
||||
;(addItem as PlainObject)[ck] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
arrayDelta.push(addItem)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1.b) Ha id ma non esiste nel backup ⇒ AddToCollection
|
||||
const backupItem = backupMap.get(fid as string | number)
|
||||
if (!backupItem) {
|
||||
const addItem = {} as DeltaArrayItem<U>
|
||||
Object.assign(addItem, formItem as Partial<U>)
|
||||
addItem.id = fid as U['id']
|
||||
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
|
||||
// ⬇️ Anche qui: manteniamo i figli, marcandoli come AddToCollection
|
||||
for (const ck of childrenKeys) {
|
||||
const v = (addItem as PlainObject)[ck]
|
||||
if (Array.isArray(v)) {
|
||||
const normalized = (v as Identifiable[]).map(child => {
|
||||
const c = {} as DeltaArrayItem<Identifiable>
|
||||
Object.assign(c, child as Partial<Identifiable>)
|
||||
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
return c
|
||||
})
|
||||
;(addItem as PlainObject)[ck] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
arrayDelta.push(addItem)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1.c) Re-parenting: root cambiata
|
||||
if (!sameRoot(formItem, backupItem)) {
|
||||
// REMOVE vecchio
|
||||
const removeItem = {} as DeltaArrayItem<U>
|
||||
removeItem.id = fid as U['id']
|
||||
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||
arrayDelta.push(removeItem)
|
||||
|
||||
// ADD nuovo
|
||||
const addItem = {} as DeltaArrayItem<U>
|
||||
Object.assign(addItem, formItem as Partial<U>)
|
||||
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
|
||||
if (dropChildren) {
|
||||
// ⬇️ SOLO qui, in caso di re-parenting e se richiesto, azzera i figli
|
||||
for (const ck of childrenKeys) {
|
||||
if (ck in (addItem as PlainObject)) {
|
||||
;(addItem as PlainObject)[ck] = []
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Mantieni i figli marcandoli come AddToCollection
|
||||
for (const ck of childrenKeys) {
|
||||
const v = (addItem as PlainObject)[ck]
|
||||
if (Array.isArray(v)) {
|
||||
const normalized = (v as Identifiable[]).map(child => {
|
||||
const c = {} as DeltaArrayItem<Identifiable>
|
||||
Object.assign(c, child as Partial<Identifiable>)
|
||||
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
|
||||
return c
|
||||
}); (addItem as PlainObject)[ck] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
arrayDelta.push(addItem)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
// 1.d) Ruolo → null ⇒ rimozione item (se abilitato)
|
||||
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
|
||||
if (deleteOnRoleNull) {
|
||||
const formRole = (formItem as PlainObject)[roleKey]
|
||||
const backupRole = (backupItem as PlainObject)[roleKey]
|
||||
const roleBecameNull = backupRole !== null && formRole === null
|
||||
if (roleBecameNull) {
|
||||
const removeItem = {} as DeltaArrayItem<U>
|
||||
removeItem.id = fid as U['id']
|
||||
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||
arrayDelta.push(removeItem)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 1.e) Diff puntuale su campi
|
||||
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
|
||||
itemDeltaBase.id = fid as U['id']
|
||||
|
||||
calculateDelta(
|
||||
formItem as PlainObject,
|
||||
backupItem as PlainObject,
|
||||
itemDeltaBase
|
||||
)
|
||||
|
||||
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id')
|
||||
if (hasMeaningfulChanges) {
|
||||
arrayDelta.push(itemDeltaBase as DeltaArrayItem<U>)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Elementi rimossi
|
||||
for (const backupItem of backupArray) {
|
||||
const bid = getId(backupItem)
|
||||
if (bid === null || bid === undefined) continue
|
||||
if (!formMap.has(bid as string | number)) {
|
||||
const removeItem = {} as DeltaArrayItem<U>
|
||||
removeItem.id = bid as U['id']
|
||||
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
|
||||
arrayDelta.push(removeItem)
|
||||
}
|
||||
}
|
||||
|
||||
return arrayDelta
|
||||
}
|
||||
|
||||
calculateDelta(
|
||||
deepCopy(formState) as PlainObject,
|
||||
deepCopy(backupState) as PlainObject,
|
||||
delta as PlainObject & OperationBag
|
||||
)
|
||||
|
||||
return delta
|
||||
}
|
||||
|
||||
export const deltaHasOperations = <T extends Record<string, unknown>>(delta: Delta<T>): boolean => {
|
||||
if (!isPlainObject(delta)) return false
|
||||
if ('operations' in delta && isPlainObject(delta.operations)) return true
|
||||
|
||||
for (const key in delta) {
|
||||
const v = (delta as PlainObject)[key]
|
||||
|
||||
if (isPlainObject(v) && deltaHasOperations(v as Delta<{}>)) return true
|
||||
|
||||
if (Array.isArray(v)) {
|
||||
for (const item of v) {
|
||||
if (isPlainObject(item) && deltaHasOperations(item as Delta<{}>)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
70
src/MaksIT.WebUI/src/functions/deep/deepEqual.ts
Normal file
70
src/MaksIT.WebUI/src/functions/deep/deepEqual.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { deepCopy } from './deepCopy.js'
|
||||
|
||||
const deepEqual = (objA: unknown, objB: unknown): boolean => {
|
||||
const copyA = deepCopy(objA)
|
||||
const copyB = deepCopy(objB)
|
||||
|
||||
if (copyA === copyB) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(copyA) && Array.isArray(copyB)) {
|
||||
return deepEqualArrays(copyA, copyB)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof copyA !== 'object' ||
|
||||
typeof copyB !== 'object' ||
|
||||
copyA === null ||
|
||||
copyB === null
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keysA = Object.keys(copyA)
|
||||
const keysB = Object.keys(copyB)
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const valA = (copyA as Record<string, unknown>)[key]
|
||||
const valB = (copyB as Record<string, unknown>)[key]
|
||||
|
||||
if (!deepEqual(valA, valB)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const deepEqualArrays = (arrA: unknown[], arrB: unknown[]): boolean => {
|
||||
const copyA = deepCopy(arrA)
|
||||
const copyB = deepCopy(arrB)
|
||||
|
||||
if (copyA.length !== copyB.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (copyA.length === 0 && copyB.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const itemA of copyA) {
|
||||
const matchIndex = copyB.findIndex((itemB) => deepEqual(itemA, itemB))
|
||||
if (matchIndex === -1) {
|
||||
return false
|
||||
}
|
||||
copyB.splice(matchIndex, 1)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export { deepEqual, deepEqualArrays }
|
||||
35
src/MaksIT.WebUI/src/functions/deep/deepMerge.ts
Normal file
35
src/MaksIT.WebUI/src/functions/deep/deepMerge.ts
Normal file
@ -0,0 +1,35 @@
|
||||
const deepMerge = <T>(target: T, source: T, seen = new WeakMap<object, unknown>()): T => {
|
||||
if (target === null || typeof target !== 'object') return source
|
||||
if (source === null || typeof source !== 'object') return target
|
||||
|
||||
if (seen.has(target as object)) {
|
||||
return seen.get(target as object) as T
|
||||
}
|
||||
|
||||
if (Array.isArray(target) && Array.isArray(source)) {
|
||||
const mergedArray: unknown[] = []
|
||||
seen.set(target, mergedArray)
|
||||
|
||||
const maxLength = Math.max(target.length, source.length)
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
mergedArray[i] = deepMerge(target[i], source[i], seen)
|
||||
}
|
||||
|
||||
return mergedArray as T
|
||||
}
|
||||
|
||||
const mergedObject = { ...target } as Record<string | number | symbol, unknown>
|
||||
seen.set(target as object, mergedObject)
|
||||
|
||||
for (const key of Object.keys(source) as Array<keyof T>) {
|
||||
const sourceValue = source[key]
|
||||
if (sourceValue !== undefined) {
|
||||
const targetValue = target[key]
|
||||
mergedObject[key] = deepMerge(targetValue, sourceValue, seen)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedObject as T
|
||||
}
|
||||
|
||||
export { deepMerge }
|
||||
18
src/MaksIT.WebUI/src/functions/deep/index.ts
Normal file
18
src/MaksIT.WebUI/src/functions/deep/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { deepCopy } from './deepCopy'
|
||||
import {
|
||||
deepDelta,
|
||||
deltaHasOperations
|
||||
} from './deepDelta'
|
||||
import { deepEqualArrays, deepEqual } from './deepEqual'
|
||||
import { deepMerge } from './deepMerge'
|
||||
|
||||
|
||||
|
||||
export {
|
||||
deepCopy,
|
||||
deepDelta,
|
||||
deltaHasOperations,
|
||||
deepEqualArrays,
|
||||
deepEqual,
|
||||
deepMerge
|
||||
}
|
||||
39
src/MaksIT.WebUI/src/functions/enum/enumToArr.ts
Normal file
39
src/MaksIT.WebUI/src/functions/enum/enumToArr.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export interface EnumArrayProps {
|
||||
value: number | string;
|
||||
displayValue: string;
|
||||
}
|
||||
|
||||
|
||||
const enumToArr = (enumType: unknown): EnumArrayProps[] => {
|
||||
if (!enumType) return []
|
||||
|
||||
const enumEntries = Object.entries(enumType)
|
||||
const addedValues = new Set()
|
||||
const result: EnumArrayProps[] = []
|
||||
|
||||
enumEntries.forEach(([key, value]) => {
|
||||
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
|
||||
if (!isNaN(Number(key))) return
|
||||
|
||||
// Skip already added values for string enums with reverse mapping
|
||||
if (addedValues.has(value)) return
|
||||
addedValues.add(value)
|
||||
|
||||
result.push({
|
||||
value: value,
|
||||
displayValue: key,
|
||||
})
|
||||
})
|
||||
|
||||
// Sort the result array by displayValue (key)
|
||||
result.sort((a, b) => {
|
||||
if (typeof a.displayValue === 'string' && typeof b.displayValue === 'string') {
|
||||
return a.displayValue.localeCompare(b.displayValue)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export { enumToArr }
|
||||
17
src/MaksIT.WebUI/src/functions/enum/enumToObj.ts
Normal file
17
src/MaksIT.WebUI/src/functions/enum/enumToObj.ts
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
const enumToObj = (enumType: unknown) => {
|
||||
if (!enumType) return {}
|
||||
|
||||
const enumEntries = Object.entries(enumType)
|
||||
const result: { [key: string]: number | string } = {}
|
||||
|
||||
enumEntries.forEach(([key, value]) => {
|
||||
// Skip numeric keys to avoid reverse mapping duplicates in numeric enums
|
||||
if (!isNaN(Number(key))) return
|
||||
result[key] = value
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export { enumToObj }
|
||||
23
src/MaksIT.WebUI/src/functions/enum/enumToString.ts
Normal file
23
src/MaksIT.WebUI/src/functions/enum/enumToString.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { EnumArrayProps, enumToArr } from './enumToArr'
|
||||
|
||||
const getEnumValue = <T>(enumType: T, enumValue: number) : EnumArrayProps | undefined => {
|
||||
return enumToArr(enumType).find((item) => item.value == enumValue)
|
||||
}
|
||||
|
||||
const enumToString = <T>(enumType: T, enumValue?: number | null): string => {
|
||||
|
||||
if (enumValue === undefined || enumValue === null) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const enumVal = getEnumValue(enumType, enumValue)
|
||||
|
||||
if (!enumVal)
|
||||
return ''
|
||||
|
||||
return `${enumVal.value} - ${enumVal.displayValue}`
|
||||
}
|
||||
|
||||
export {
|
||||
enumToString
|
||||
}
|
||||
10
src/MaksIT.WebUI/src/functions/enum/flagsToString.ts
Normal file
10
src/MaksIT.WebUI/src/functions/enum/flagsToString.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { enumToArr } from './enumToArr'
|
||||
|
||||
const flagsToString = <T>(enumType: T, flags: number): string => {
|
||||
return enumToArr(enumType)
|
||||
.filter(opt => (flags & opt.value as number) === opt.value && opt.value !== 0)
|
||||
.map(opt => opt.displayValue)
|
||||
.join(', ') || 'None'
|
||||
}
|
||||
|
||||
export { flagsToString }
|
||||
5
src/MaksIT.WebUI/src/functions/enum/hasAnyFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/hasAnyFlag.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const hasAnyFlag = <T extends number>(current: T = 0 as T, flags: T): boolean => {
|
||||
return (current & flags) !== 0
|
||||
}
|
||||
|
||||
export { hasAnyFlag }
|
||||
5
src/MaksIT.WebUI/src/functions/enum/hasFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/hasFlag.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const hasFlag = <T extends number>(current: T = 0 as T, flag: T): boolean => {
|
||||
return (current & flag) === flag
|
||||
}
|
||||
|
||||
export { hasFlag }
|
||||
37
src/MaksIT.WebUI/src/functions/enum/index.ts
Normal file
37
src/MaksIT.WebUI/src/functions/enum/index.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
enumToArr
|
||||
} from './enumToArr'
|
||||
|
||||
import {
|
||||
enumToObj
|
||||
} from './enumToObj'
|
||||
|
||||
import {
|
||||
enumToString
|
||||
} from './enumToString'
|
||||
|
||||
import {
|
||||
flagsToString
|
||||
} from './flagsToString'
|
||||
|
||||
import {
|
||||
toggleFlag
|
||||
} from './toggleFlag'
|
||||
|
||||
import {
|
||||
hasFlag
|
||||
} from './hasFlag'
|
||||
|
||||
import {
|
||||
hasAnyFlag
|
||||
} from './hasAnyFlag'
|
||||
|
||||
export {
|
||||
enumToArr,
|
||||
enumToObj,
|
||||
enumToString,
|
||||
flagsToString,
|
||||
toggleFlag,
|
||||
hasFlag,
|
||||
hasAnyFlag
|
||||
}
|
||||
5
src/MaksIT.WebUI/src/functions/enum/toggleFlag.ts
Normal file
5
src/MaksIT.WebUI/src/functions/enum/toggleFlag.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const toggleFlag = <T extends number>(current: T = 0 as T, flag: T): T => {
|
||||
return ((current & flag) === flag ? (current & ~flag) : (current | flag)) as T
|
||||
}
|
||||
|
||||
export { toggleFlag }
|
||||
55
src/MaksIT.WebUI/src/functions/index.ts
Normal file
55
src/MaksIT.WebUI/src/functions/index.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
isValidISODateString,
|
||||
formatISODateString
|
||||
} from './date'
|
||||
|
||||
import {
|
||||
deepCopy,
|
||||
deepDelta,
|
||||
deltaHasOperations,
|
||||
deepEqual,
|
||||
deepMerge,
|
||||
} from './deep'
|
||||
|
||||
import {
|
||||
enumToArr,
|
||||
enumToObj,
|
||||
enumToString,
|
||||
flagsToString,
|
||||
toggleFlag,
|
||||
hasFlag,
|
||||
hasAnyFlag
|
||||
} from './enum'
|
||||
|
||||
import {
|
||||
isGuid
|
||||
} from './isGuid'
|
||||
|
||||
import {
|
||||
parseAclEntry,
|
||||
parseAclEntries
|
||||
} from './acl'
|
||||
|
||||
export {
|
||||
isValidISODateString,
|
||||
formatISODateString,
|
||||
|
||||
deepCopy,
|
||||
deepDelta,
|
||||
deltaHasOperations,
|
||||
deepEqual,
|
||||
deepMerge,
|
||||
|
||||
enumToArr,
|
||||
enumToObj,
|
||||
enumToString,
|
||||
flagsToString,
|
||||
toggleFlag,
|
||||
hasFlag,
|
||||
hasAnyFlag,
|
||||
|
||||
isGuid,
|
||||
|
||||
parseAclEntry,
|
||||
parseAclEntries
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user